From 8e8a37e683eed9262fe6d19d236fa1b2c5c85b52 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Sun, 27 Sep 2026 22:40:16 -0700 Subject: [PATCH 01/49] ci: re-arm release-merge on every check-producing workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-sync release-merge.yml from the CLDMV/.github v4.29.2 template. The merge gate waits on every check-run on the release PR's head, but workflow_run only listed the CI workflow, so an approval given before CodeQL (or any other non-CI check) finished left the release PR stuck approved and green with nothing re-firing the merge check — exactly what happened to CLDMV/.github PR #322. The trigger now lists every workflow in the standard v4 set that puts a check on a release-PR head, and `branches: [next, hotfixes]` stops feature-branch runs from waking it. Refs CLDMV/.github#318 --- .github/workflows/release-merge.yml | 41 ++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release-merge.yml b/.github/workflows/release-merge.yml index 7c83e02a2..106ab85a4 100644 --- a/.github/workflows/release-merge.yml +++ b/.github/workflows/release-merge.yml @@ -30,13 +30,17 @@ # merge check, because check_suite silently never fired for that case at all. # `workflow_run` is GitHub's documented replacement for exactly this pattern. # -# IMPORTANT: `workflows:` below must list the exact `name:` of THIS repo's own -# ci.yml (matched by literal workflow name, not filename) — update it if you -# renamed that workflow. If more than one top-level workflow in your repo can -# plausibly be the last to finish (e.g. a separately-scheduled CodeQL run that -# sometimes outlasts ci.yml), list all of them; a workflow that finishes and -# isn't listed here won't re-arm the merge check, though the manual -# workflow_dispatch fallback below always will. +# IMPORTANT: the merge gate waits on EVERY check-run on the release PR's head +# commit, from any workflow — so `workflows:` below must name every workflow in +# your repo that puts a check on that commit, not just ci.yml. Only the one that +# finishes LAST can see a fully green head; if it isn't listed, nothing re-fires +# and the PR sits approved + green until you dispatch this workflow by hand. +# CodeQL in particular routinely outlasts ci.yml (CLDMV/.github PR #322 stuck +# exactly this way). The list below covers the standard v4 template set by their +# template `name:`s — names are matched literally (not by filename), so fix any +# you renamed, and add any extra PR-triggered workflow your repo has. Listing a +# workflow your repo doesn't have is harmless. `branches:` limits re-fires to +# runs on the integration branches, so feature-branch CI doesn't wake this up. # # Thin caller: all logic lives in the reusable, pinned at @v4. # NOTE: pull_request_review / workflow_run events run the copy of this file on @@ -46,13 +50,32 @@ name: 🚦 Release Merge (v4) on: pull_request_review: types: [submitted] # your approval arms it - workflow_run: - workflows: ["🧪 CI Tests & Build"] # ← match your ci.yml's `name:` — see #318 + workflow_run: # re-evaluate as each check-producing workflow finishes — see #318 + workflows: # ← must match your workflows' `name:`s — see IMPORTANT above + - "🧪 CI Tests & Build" + - "🔍 CodeQL" + - "🔒 Dependency Review" + - "📊 Bundle Size" + - "🚀 Next Release (v4)" + - "🚑 Hotfixes Release (v4)" + - "🌿 Branch Retention" + - "🏷️ PR Labeler" + - "🏷️ PR Title Normalizer" + - "👋 Welcome Contributor" + - "🔀 Hotfix PR Redirector (v4)" + - "🚀 Member Auto-Enable Auto-Merge" + - "🤖 Dependabot Auto-Merge" + - "🔁 Dependabot Auto-Recreate" + - "📜 CLA" + - "📥 PR Notify" types: [completed] + branches: [next, hotfixes] workflow_dispatch: # manual re-evaluation # Serialize per repo: each run re-resolves the release PR + re-gates the head, # so queue (don't cancel) to avoid racing a merge that's already in flight. +# GitHub keeps one pending run per group and a newer arrival replaces it — that +# is harmless, since every run re-reads approval + check state from the API. concurrency: group: release-merge-${{ github.repository }} cancel-in-progress: false From 0c31d5c2460b2c9e0e221114f3b71f55dd629e56 Mon Sep 17 00:00:00 2001 From: "cldmv-bot[bot]" <230771808+cldmv-bot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:45:59 +0000 Subject: [PATCH 02/49] chore: bump version to 3.21.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index c478acad4..ae5776303 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cldmv/slothlet", - "version": "3.21.0", + "version": "3.21.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cldmv/slothlet", - "version": "3.21.0", + "version": "3.21.1", "license": "Apache-2.0", "bin": { "slothlet": "bin/slothlet.mjs" diff --git a/package.json b/package.json index e5fe4f5ae..3822180e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cldmv/slothlet", - "version": "3.21.0", + "version": "3.21.1", "moduleVersions": { "lazy": "3.0.0", "eager": "3.0.0", From 44b285eb7a5a5f5878e5a860c93e8843f5ed0e4f Mon Sep 17 00:00:00 2001 From: Shinrai Date: Sun, 27 Sep 2026 23:09:27 -0700 Subject: [PATCH 03/49] fix(api): remove() no longer throws on a module's frozen or sealed exports api.slothlet.api.remove() aborted with "Cannot delete property" when the removed module exported a frozen/sealed object (e.g. a constant table), leaving stale leaves on the api and an invalidated function leaf that a re-add of the same moduleID did not restore. A wrapper over such an export now reports a non-deletable key the way the object itself does (the deleteProperty trap returns false), and deletePath() detaches with Reflect.deleteProperty so that answer no longer aborts the removal. A user `delete` through the wrapper keeps the object's own semantics: a strict-mode TypeError for a frozen key, a normal delete otherwise. Fixes #485 --- src/lib/handlers/api-manager.mjs | 15 +- src/lib/handlers/unified-wrapper.mjs | 9 +- ...nager-remove-frozen-export.test.vitest.mjs | 154 ++++++++++++++++++ types/src/lib/handlers/api-manager.d.mts.map | 2 +- .../lib/handlers/unified-wrapper.d.mts.map | 2 +- 5 files changed, 173 insertions(+), 9 deletions(-) create mode 100644 tests/vitests/suites/api-manager/api-manager-remove-frozen-export.test.vitest.mjs diff --git a/src/lib/handlers/api-manager.mjs b/src/lib/handlers/api-manager.mjs index fb216bcf5..857bcba5c 100644 --- a/src/lib/handlers/api-manager.mjs +++ b/src/lib/handlers/api-manager.mjs @@ -1499,12 +1499,18 @@ export class ApiManager extends ComponentBase { // impl is a function, or an absent impl) is a defensive shape guard not exercised here. /* v8 ignore else */ if (wrapper.____slothletInternal.impl && typeof wrapper.____slothletInternal.impl === "object") { - delete wrapper.____slothletInternal.impl[finalKey]; + // The impl can be the module's own frozen/sealed export (#485). Reflect.deleteProperty + // reports a non-configurable key with `false` instead of throwing; the key then stays on + // the module's object, and the wrapper is invalidated when its own path is removed. + Reflect.deleteProperty(wrapper.____slothletInternal.impl, finalKey); } } - // Also delete the property from the object/proxy itself - delete current[finalKey]; + // Also delete the property from the object/proxy itself. A wrapper over a module's frozen/sealed + // export reports the key as non-deletable (`false`); Reflect.deleteProperty takes that answer + // instead of throwing, so one frozen constant table cannot abort the rest of the removal (#485). + // The removed node's wrapper is invalidated below regardless. + Reflect.deleteProperty(current, finalKey); // AFTER deletion, clean up wrapper state for removed proxy // removedImpl from deletePath is always an object or function; the FALSE branch is unreachable. @@ -1555,7 +1561,8 @@ export class ApiManager extends ComponentBase { const { parent, key } = stack[i]; const value = parent[key]; if (value && (typeof value === "object" || typeof value === "function") && Object.keys(value).length === 0) { - delete parent[key]; + // Same as the detach above: a frozen parent answers `false` rather than throwing (#485). + Reflect.deleteProperty(parent, key); } } diff --git a/src/lib/handlers/unified-wrapper.mjs b/src/lib/handlers/unified-wrapper.mjs index 79ba73ae2..e3498c924 100644 --- a/src/lib/handlers/unified-wrapper.mjs +++ b/src/lib/handlers/unified-wrapper.mjs @@ -4962,19 +4962,22 @@ export class UnifiedWrapper extends ComponentBase { } } - // Remove from _impl if it's an object + // Remove from _impl if it's an object. The impl can be the module's own frozen/sealed export + // (#485): report a non-deletable key the way the object itself would — `false`, which a + // strict-mode `delete` turns into the same TypeError a direct delete on that object throws. + let deleted = true; if ( wrapper.____slothletInternal.impl && typeof wrapper.____slothletInternal.impl === "object" && prop in wrapper.____slothletInternal.impl ) { - delete wrapper.____slothletInternal.impl[prop]; + deleted = Reflect.deleteProperty(wrapper.____slothletInternal.impl, prop); } // Remove from proxy target delete target[prop]; - return true; + return deleted; }; /** diff --git a/tests/vitests/suites/api-manager/api-manager-remove-frozen-export.test.vitest.mjs b/tests/vitests/suites/api-manager/api-manager-remove-frozen-export.test.vitest.mjs new file mode 100644 index 000000000..03d55f7ec --- /dev/null +++ b/tests/vitests/suites/api-manager/api-manager-remove-frozen-export.test.vitest.mjs @@ -0,0 +1,154 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/api-manager/api-manager-remove-frozen-export.test.vitest.mjs + * @Date: 2026-09-27 21:37:01 -07:00 (1790570221) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-27 21:53:07 -07:00 (1790571187) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Regression (#485): removing a module whose export tree contains a frozen plain object + * (the common idiom for an exported constant table) must not mutate that object. `deletePath()` fell + * through to a native `delete` on the consumer's own object, which throws in strict mode when the object + * is frozen — aborting the removal partway and leaving stale leaves on the api plus an invalidated + * function leaf that a re-add of the same moduleID did not restore. + * + * @module tests/vitests/suites/api-manager/api-manager-remove-frozen-export + */ + +import { describe, it, expect, afterEach } from "vitest"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import slothlet from "@cldmv/slothlet"; +import { makeTestTmpDir } from "../../setup/test-fixtures-tmp.mjs"; + +/** + * Write a fixture module, creating its folder. + * @param {string} dir - Folder to write into. + * @param {string} name - File name. + * @param {string} code - Module source. + * @returns {Promise} + */ +async function writeModule(dir, name, code) { + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, name), code); +} + +// The module keeps a handle on its own frozen objects so the test can prove they were left untouched. +const FROZEN_ERRORS = ` +const CODES = Object.freeze({ NOT_FOUND: "not-found", CONFLICT: "conflict" }); +export const errors = Object.freeze({ CODES, isStoreError: (err) => !!err }); +globalThis.__slothletFrozenProbe ??= []; +globalThis.__slothletFrozenProbe.push({ CODES, errors }); +`; + +const SEALED = ` +export const limits = Object.seal({ max: 10, min: 1 }); +export const fixed = {}; +Object.defineProperty(fixed, "answer", { value: 42, enumerable: true, configurable: false }); +`; + +describe.each([{ mode: "eager" }, { mode: "lazy" }])("api-manager — remove a module exporting frozen objects ($mode)", ({ mode }) => { + let api; + const dirs = []; + + afterEach(async () => { + if (api?.shutdown) await api.shutdown().catch(() => {}); + api = null; + delete globalThis.__slothletFrozenProbe; + for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }); + }); + + it("removes a frozen nested plain-object export without throwing or mutating it", async () => { + const root = await makeTestTmpDir(`remove-frozen-${mode}`); + dirs.push(root); + const store = join(root, "store"); + await writeModule(store, "errors.mjs", FROZEN_ERRORS); + + api = await slothlet({ mode, silent: true }); + await api.slothlet.api.add(["store"], store, { moduleID: "m" }); + expect(api.store.errors.CODES.NOT_FOUND).toBe("not-found"); + expect(await api.store.errors.isStoreError(new Error("x"))).toBe(true); + + await expect(api.slothlet.api.remove("m")).resolves.not.toThrow(); + + // The whole module is detached — nothing stale stays reachable. + expect(api.store).toBeUndefined(); + + // The module's own frozen objects are untouched. + for (const { CODES, errors } of globalThis.__slothletFrozenProbe) { + expect(CODES).toEqual({ NOT_FOUND: "not-found", CONFLICT: "conflict" }); + expect(Object.isFrozen(CODES)).toBe(true); + expect(typeof errors.isStoreError).toBe("function"); + } + + // Re-adding the same moduleID restores a fully working module. + await api.slothlet.api.add(["store"], store, { moduleID: "m" }); + expect(api.store.errors.CODES.NOT_FOUND).toBe("not-found"); + expect(api.store.errors.CODES.CONFLICT).toBe("conflict"); + expect(await api.store.errors.isStoreError(new Error("x"))).toBe(true); + }); + + it("removes sealed and non-configurable exports", async () => { + const root = await makeTestTmpDir(`remove-sealed-${mode}`); + dirs.push(root); + const cfg = join(root, "cfg"); + await writeModule(cfg, "values.mjs", SEALED); + + api = await slothlet({ mode, silent: true }); + await api.slothlet.api.add(["cfg"], cfg, { moduleID: "sealed" }); + expect(api.cfg.values.limits.max).toBe(10); + expect(api.cfg.values.fixed.answer).toBe(42); + + await expect(api.slothlet.api.remove("sealed")).resolves.not.toThrow(); + expect(api.cfg).toBeUndefined(); + }); + + it("a user `delete` through the wrapper behaves like the underlying object", async () => { + const root = await makeTestTmpDir(`delete-frozen-${mode}`); + dirs.push(root); + const store = join(root, "store"); + await writeModule(store, "errors.mjs", FROZEN_ERRORS); + await writeModule(store, "table.mjs", `export const table = { a: 1, b: 2 };`); + + api = await slothlet({ mode, silent: true }); + await api.slothlet.api.add(["store"], store, { moduleID: "m" }); + + // Frozen: the same strict-mode TypeError a direct delete on the frozen object throws, value kept. + const codes = api.store.errors.CODES; + expect(() => { + "use strict"; + delete codes.NOT_FOUND; + }).toThrow(TypeError); + expect(api.store.errors.CODES.NOT_FOUND).toBe("not-found"); + + // Mutable: the delete goes through. + const table = api.store.table; + expect(table.a).toBe(1); + delete table.a; + expect(table.a).toBeUndefined(); + expect(table.b).toBe(2); + }); + + it("keeps sibling modules when a frozen-export module is removed", async () => { + const root = await makeTestTmpDir(`remove-frozen-sib-${mode}`); + dirs.push(root); + const store = join(root, "store"); + const other = join(root, "other"); + await writeModule(store, "errors.mjs", FROZEN_ERRORS); + await writeModule(other, "ping.mjs", "export function ping() { return 'pong'; }"); + + api = await slothlet({ mode, silent: true }); + await api.slothlet.api.add(["store"], store, { moduleID: "m" }); + await api.slothlet.api.add(["other"], other, { moduleID: "o" }); + + await api.slothlet.api.remove("m"); + expect(api.store).toBeUndefined(); + expect(await api.other.ping()).toBe("pong"); + }); +}); diff --git a/types/src/lib/handlers/api-manager.d.mts.map b/types/src/lib/handlers/api-manager.d.mts.map index 6987b343e..faa30e3f3 100644 --- a/types/src/lib/handlers/api-manager.d.mts.map +++ b/types/src/lib/handlers/api-manager.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"api-manager.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/api-manager.mjs"],"names":[],"mappings":"AAgFA;;;;;;;;;;;;;;GAcG;AACH;IACC,gCAAuC;IAEvC;;;;;;;;;;;OAWG;IACH,sBAVW,MAAM,EAuBhB;IAXA,sIAAsI;IACtI,OADW;QAAE,UAAU,EAAE,MAAM,EAAE,CAAC;QAAC,aAAa,EAAE,MAAM,GAAC,IAAI,CAAC;QAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC;QAAC,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;KAAE,CAUjI;IAGF;;;;;;;;;;;;;;;;;;OAkBG;IACH,yBA+GC;IAED;;;;;;;;;;;;;;OAcG;IACH,oBA4CC;IAED;;;;;;;;;;;;;;OAcG;IACH,0BA4BC;IAED;;;;;;;;;;;;OAYG;IACH,6BAIC;IAED;;;;;;;;;;;;OAYG;IACH,uBASC;IAED;;;;;;;;;;;;;OAaG;IACH,yBAkCC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAiCG;IACH,iCAbW,MAAM,SACN,OAAO,iBACP,MAAM,GAAC,IAAI,GACT,IAAI,CAqHhB;IAED;;;;;;;;;;;;;OAaG;IACH,uBAEC;IAED;;;;;;;;;;;;;;OAcG;IACH,oBAoVC;IAED;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,8BAiCC;IAED;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,sCAmBC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,uBAyGC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,uBA6MC;IAED;;;;;;;;;;;;OAYG;IACH,mBAiHC;IACD;;;;;;;;;;;;OAYG;IACH,uBAsEC;IA6CD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAkDG;IACH,wBAhDG;QAAuB,OAAO,EAAtB,MAAM;QACmD,UAAU,EAAnE,MAAM,GAAC,MAAM,EAAE,cAAU,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;QACf,OAAO;KAChD,GAAU,OAAO,CAAC,MAAM,GAAC,MAAM,EAAE,CAAC,CAu+BpC;IAED;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,yEATG;QAAqB,QAAQ,EAArB,MAAM;QACO,aAAa,EAA1B,MAAM;QACO,cAAc,EAA3B,MAAM;KACd,GAAU,OAAO,CAAC,IAAI,CAAC,CAgDzB;IA8BD;;;;;;;;;;;OAWG;IACH,mCAeC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;OA2BG;IACH,mCA1BW,MAAM,YAEd;QAAyB,aAAa;QAGZ,aAAa;KACvC,GAAU,OAAO,CAAC,OAAO,CAAC,CA+nB5B;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,2BAdG;QAAwB,OAAO,EAAtB,MAAM,OAAA;QACS,QAAQ,EAAvB,MAAM,OAAA;KACf,GAAU,OAAO,CAAC,IAAI,CAAC,CAuCzB;IAED;;;;;;;;;;OAUG;IACH,0BA0EC;IAED;;;;;;;;;;;;;;OAcG;IACH,yBAsFC;IAED;;;;;;;;;;;;;OAaG;IACH,4BA0FC;IAED;;;;;;;;OAQG;IACH,iCAyDC;IAED;;;;;OAKG;IACH,iCAeC;IAED;;;;;;;;;;;;;OAaG;IACH,wBAoUC;;CACD;8BArmI6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"api-manager.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/api-manager.mjs"],"names":[],"mappings":"AAgFA;;;;;;;;;;;;;;GAcG;AACH;IACC,gCAAuC;IAEvC;;;;;;;;;;;OAWG;IACH,sBAVW,MAAM,EAuBhB;IAXA,sIAAsI;IACtI,OADW;QAAE,UAAU,EAAE,MAAM,EAAE,CAAC;QAAC,aAAa,EAAE,MAAM,GAAC,IAAI,CAAC;QAAC,gBAAgB,EAAE,MAAM,EAAE,CAAC;QAAC,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAA;KAAE,CAUjI;IAGF;;;;;;;;;;;;;;;;;;OAkBG;IACH,yBA+GC;IAED;;;;;;;;;;;;;;OAcG;IACH,oBA4CC;IAED;;;;;;;;;;;;;;OAcG;IACH,0BA4BC;IAED;;;;;;;;;;;;OAYG;IACH,6BAIC;IAED;;;;;;;;;;;;OAYG;IACH,uBASC;IAED;;;;;;;;;;;;;OAaG;IACH,yBAkCC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAiCG;IACH,iCAbW,MAAM,SACN,OAAO,iBACP,MAAM,GAAC,IAAI,GACT,IAAI,CAqHhB;IAED;;;;;;;;;;;;;OAaG;IACH,uBAEC;IAED;;;;;;;;;;;;;;OAcG;IACH,oBAoVC;IAED;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,8BAiCC;IAED;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,sCAmBC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,uBAyGC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,uBA6MC;IAED;;;;;;;;;;;;OAYG;IACH,mBAwHC;IACD;;;;;;;;;;;;OAYG;IACH,uBAsEC;IA6CD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAkDG;IACH,wBAhDG;QAAuB,OAAO,EAAtB,MAAM;QACmD,UAAU,EAAnE,MAAM,GAAC,MAAM,EAAE,cAAU,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;QACf,OAAO;KAChD,GAAU,OAAO,CAAC,MAAM,GAAC,MAAM,EAAE,CAAC,CAu+BpC;IAED;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,yEATG;QAAqB,QAAQ,EAArB,MAAM;QACO,aAAa,EAA1B,MAAM;QACO,cAAc,EAA3B,MAAM;KACd,GAAU,OAAO,CAAC,IAAI,CAAC,CAgDzB;IA8BD;;;;;;;;;;;OAWG;IACH,mCAeC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;OA2BG;IACH,mCA1BW,MAAM,YAEd;QAAyB,aAAa;QAGZ,aAAa;KACvC,GAAU,OAAO,CAAC,OAAO,CAAC,CA+nB5B;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,2BAdG;QAAwB,OAAO,EAAtB,MAAM,OAAA;QACS,QAAQ,EAAvB,MAAM,OAAA;KACf,GAAU,OAAO,CAAC,IAAI,CAAC,CAuCzB;IAED;;;;;;;;;;OAUG;IACH,0BA0EC;IAED;;;;;;;;;;;;;;OAcG;IACH,yBAsFC;IAED;;;;;;;;;;;;;OAaG;IACH,4BA0FC;IAED;;;;;;;;OAQG;IACH,iCAyDC;IAED;;;;;OAKG;IACH,iCAeC;IAED;;;;;;;;;;;;;OAaG;IACH,wBAoUC;;CACD;8BA5mI6B,2BAA2B"} \ No newline at end of file diff --git a/types/src/lib/handlers/unified-wrapper.d.mts.map b/types/src/lib/handlers/unified-wrapper.d.mts.map index 18cdec8b7..ecabaa419 100644 --- a/types/src/lib/handlers/unified-wrapper.d.mts.map +++ b/types/src/lib/handlers/unified-wrapper.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"unified-wrapper.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/unified-wrapper.mjs"],"names":[],"mappings":"AA0xBA;;;;;;;;;;;;;;;;;;GAkBG;AACH,4CAhBW,MAAM,GAAC,MAAM,GACX,OAAO,CAkBnB;AA+vID;;;;;;;;;;;GAWG;AACH,sCAPW,OAAO,GACL,cAAc,GAAC,IAAI,CAkB/B;AAxzID;;;;;;;;GAQG;AACH,iCAHU,GAAG,CAAC,MAAM,CAAC,CAG2F;;;;;AAuEhH;;;;;;;;;;GAUG;AACH;IAiTC;;;;;;;;;;;;;;;;OAgBG;IACH,0BAsCC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,gCAiEC;IAnaD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAiCG;IACH,sBAjCW,MAAM,uJAEd;QAAwB,IAAI,EAApB,MAAM;QACU,OAAO,EAAvB,MAAM;QACyB,WAAW;QACvB,eAAe;QAChB,UAAU;QACV,mBAAmB;QACpB,QAAQ;QACR,QAAQ;QACR,YAAY;QACE,cAAc;QAI3B,eAAe;KAMzC,EA4MF;IAnPD;;;;;;;;;;;OAWG;IACH,4BAFa,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAC,SAAS,CAKzC;IAsOD;;;;;;;;;;OAUG;IACH,uEAHa,GAAC,CA+Bb;IAED;;;;OAIG;IACH,qBAHa,WAAS,MAAM,GAAC,IAAI,CAKhC;IAiJD;;;;;;;;;;;OAWG;IACH,sBAiCC;IAED;;;;;;;;;;;;OAYG;IACH,mBAgDC;IAED;;;;;;;;;OASG;IACH,qBAsDC;IAED;;;;OAIG;IACH,uBA4GC;IAED;;;;;;;;;OASG;IACH,qBAEC;IAED;;;;;;;;;OASG;IACH,sBAiBC;IAED;;;;;;;;;;OAUG;IACH,6BA4gBC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BAgOC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BA43BC;IAED;;;;;;OAMG;IACH,uCAmwDC;;CACD;8BApgK6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"unified-wrapper.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/unified-wrapper.mjs"],"names":[],"mappings":"AA0xBA;;;;;;;;;;;;;;;;;;GAkBG;AACH,4CAhBW,MAAM,GAAC,MAAM,GACX,OAAO,CAkBnB;AAkwID;;;;;;;;;;;GAWG;AACH,sCAPW,OAAO,GACL,cAAc,GAAC,IAAI,CAkB/B;AA3zID;;;;;;;;GAQG;AACH,iCAHU,GAAG,CAAC,MAAM,CAAC,CAG2F;;;;;AAuEhH;;;;;;;;;;GAUG;AACH;IAiTC;;;;;;;;;;;;;;;;OAgBG;IACH,0BAsCC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,gCAiEC;IAnaD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAiCG;IACH,sBAjCW,MAAM,uJAEd;QAAwB,IAAI,EAApB,MAAM;QACU,OAAO,EAAvB,MAAM;QACyB,WAAW;QACvB,eAAe;QAChB,UAAU;QACV,mBAAmB;QACpB,QAAQ;QACR,QAAQ;QACR,YAAY;QACE,cAAc;QAI3B,eAAe;KAMzC,EA4MF;IAnPD;;;;;;;;;;;OAWG;IACH,4BAFa,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAC,SAAS,CAKzC;IAsOD;;;;;;;;;;OAUG;IACH,uEAHa,GAAC,CA+Bb;IAED;;;;OAIG;IACH,qBAHa,WAAS,MAAM,GAAC,IAAI,CAKhC;IAiJD;;;;;;;;;;;OAWG;IACH,sBAiCC;IAED;;;;;;;;;;;;OAYG;IACH,mBAgDC;IAED;;;;;;;;;OASG;IACH,qBAsDC;IAED;;;;OAIG;IACH,uBA4GC;IAED;;;;;;;;;OASG;IACH,qBAEC;IAED;;;;;;;;;OASG;IACH,sBAiBC;IAED;;;;;;;;;;OAUG;IACH,6BA4gBC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BAgOC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BA43BC;IAED;;;;;;OAMG;IACH,uCAswDC;;CACD;8BAvgK6B,2BAA2B"} \ No newline at end of file From a281499cead929c368beb5e830b8728f683d526b Mon Sep 17 00:00:00 2001 From: Shinrai Date: Sun, 27 Sep 2026 23:23:26 -0700 Subject: [PATCH 04/49] chore(tools): make analyze and i18n:check skip gitignored paths Both scanners walked the tree with hand-rolled readdir loops that never consulted .gitignore: analyze recursed into every folder (including nested node_modules fixtures) and filtered a hard-coded list afterwards, and i18n:check skipped only node_modules/.git. A gitignored file inside a scanned folder (a .slothlet-cache/ under api_tests, a "* copy.mjs") was reported as missing its file header. A shared tools/lib/gitignore.mjs builds a matcher from the root .gitignore with the `ignore` package (the matcher @cldmv/fix-headers already uses). Both walkers now skip ignored entries and prune ignored or excluded directories before descending into them. Output on the current tree is unchanged. --- package-lock.json | 27 +++++++-------- package.json | 1 + tools/ci/check-i18n-languages.mjs | 4 +++ tools/dev/analyze-errors.mjs | 26 +++++++++------ tools/lib/gitignore.mjs | 55 +++++++++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 23 deletions(-) create mode 100644 tools/lib/gitignore.mjs diff --git a/package-lock.json b/package-lock.json index ae5776303..76e7c246b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "eslint": "^10.7.0", "estree-walker": "^3.0.3", "globals": "^17.7.0", + "ignore": "^7.0.10", "jsdoc": "^4.0.5", "jsdoc-to-markdown": "^9.1.3", "jsdoc2md": "^1.0.0", @@ -186,16 +187,6 @@ "vitest": ">=1.0.0" } }, - "node_modules/@cldmv/fix-headers/node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, "node_modules/@cldmv/vitest-runner": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@cldmv/vitest-runner/-/vitest-runner-1.2.0.tgz", @@ -2442,6 +2433,16 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/espree": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", @@ -2953,9 +2954,9 @@ "license": "MIT" }, "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "version": "7.0.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.10.tgz", + "integrity": "sha512-HpbUakT7xp5miBUywCHf36ZEuAJNklBJDDsGpUIjMzOSmM8ELSfA9Sa/QDPeNeqeoN31u+UTCkL4klCOVvRm4Q==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 3822180e3..4e302c78e 100644 --- a/package.json +++ b/package.json @@ -332,6 +332,7 @@ "eslint": "^10.7.0", "estree-walker": "^3.0.3", "globals": "^17.7.0", + "ignore": "^7.0.10", "jsdoc": "^4.0.5", "jsdoc-to-markdown": "^9.1.3", "jsdoc2md": "^1.0.0", diff --git a/tools/ci/check-i18n-languages.mjs b/tools/ci/check-i18n-languages.mjs index 865f94c72..25c42b3ef 100644 --- a/tools/ci/check-i18n-languages.mjs +++ b/tools/ci/check-i18n-languages.mjs @@ -14,6 +14,7 @@ import { readdirSync, readFileSync } from "fs"; import { dirname, extname, join, basename } from "path"; import { fileURLToPath } from "url"; +import { loadGitignore } from "../lib/gitignore.mjs"; /** * Safely parse a JSON file. @@ -166,6 +167,7 @@ function findLatinRuns(value, tokenAllowlist) { */ function loadUsedCodeTokens(repoRoot) { const tokens = new Set(); + const isGitignored = loadGitignore(repoRoot); const walk = (dir) => { let entries; try { @@ -176,6 +178,8 @@ function loadUsedCodeTokens(repoRoot) { for (const entry of entries) { if (entry.name === "node_modules" || entry.name === ".git") continue; const full = join(dir, entry.name); + // Skip gitignored paths (scratch, caches, build output); an ignored directory is not descended. + if (isGitignored(full, entry.isDirectory())) continue; if (entry.isDirectory()) { walk(full); } else if (entry.isFile() && /\.(mjs|cjs|js)$/.test(entry.name)) { diff --git a/tools/dev/analyze-errors.mjs b/tools/dev/analyze-errors.mjs index 68bed3d53..b5837cf69 100644 --- a/tools/dev/analyze-errors.mjs +++ b/tools/dev/analyze-errors.mjs @@ -50,12 +50,15 @@ import { fileURLToPath } from "node:url"; import { exec } from "node:child_process"; import { promisify } from "node:util"; import { FILE_HEADER_CHECK_FOLDERS, FILE_HEADER_IGNORE_FOLDERS, FILE_HEADER_EXTENSIONS } from "../lib/header-config.mjs"; +import { loadGitignore } from "../lib/gitignore.mjs"; const execAsync = promisify(exec); const __dirname = fileURLToPath(new URL(".", import.meta.url)); const rootDir = join(__dirname, "../.."); const srcDir = join(rootDir, "src"); +// Every walk below skips what the project's .gitignore excludes (scratch, caches, build output). +const isGitignored = loadGitignore(rootDir); // CLI args const limitArg = process.argv.find((arg) => arg.startsWith("--limit=")); @@ -92,18 +95,23 @@ function shouldIgnorePath(filePath, ignoreFolders) { } /** - * Recursively find all .mjs files + * Recursively find all .mjs files. Gitignored entries are skipped, and an ignored directory is + * pruned before it is descended into. * @internal */ -async function findMjsFiles(dir, files = [], extensions = [".mjs"]) { +async function findMjsFiles(dir, files = [], extensions = [".mjs"], skipPath = () => false) { const entries = await readdir(dir); for (const entry of entries) { const fullPath = join(dir, entry); const stats = await stat(fullPath); + const isDirectory = stats.isDirectory(); - if (stats.isDirectory()) { - await findMjsFiles(fullPath, files, extensions); + if (isGitignored(fullPath, isDirectory) || skipPath(fullPath)) { + continue; + } + if (isDirectory) { + await findMjsFiles(fullPath, files, extensions, skipPath); } else if (extensions.some((ext) => entry.endsWith(ext))) { files.push(fullPath); } @@ -130,18 +138,16 @@ async function findMjsFilesInFolders(folderConfigs, ignoreFolders, extensions = } if (config.recursive) { - // Recursive search - const files = await findMjsFiles(folderPath, [], extensions); - // Filter out ignored paths - const filteredFiles = files.filter((file) => !shouldIgnorePath(file, ignoreFolders)); - allFiles.push(...filteredFiles); + // Recursive search — ignored folders are pruned during the walk, not filtered afterwards. + const files = await findMjsFiles(folderPath, [], extensions, (path) => shouldIgnorePath(path, ignoreFolders)); + allFiles.push(...files); } else { // Non-recursive - only get direct .mjs files const entries = await readdir(folderPath); for (const entry of entries) { if (extensions.some((ext) => entry.endsWith(ext))) { const fullPath = join(folderPath, entry); - if (!shouldIgnorePath(fullPath, ignoreFolders)) { + if (!shouldIgnorePath(fullPath, ignoreFolders) && !isGitignored(fullPath)) { allFiles.push(fullPath); } } diff --git a/tools/lib/gitignore.mjs b/tools/lib/gitignore.mjs new file mode 100644 index 000000000..f742a352f --- /dev/null +++ b/tools/lib/gitignore.mjs @@ -0,0 +1,55 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tools/lib/gitignore.mjs + * @Date: 2026-09-27 23:12:05 -07:00 (1790575925) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-27 23:22:33 -07:00 (1790576553) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview `.gitignore` matching for the repo's own file scanners. + * + * @description + * The dev/CI scanners (`tools/dev/analyze-errors.mjs`, `tools/ci/check-i18n-languages.mjs`) walk the + * source tree themselves. This lets them skip whatever the project's `.gitignore` excludes — scratch, + * caches, build output — and prune an ignored directory before descending into it, instead of each + * scanner keeping its own hard-coded list. Matching uses the `ignore` package, the same matcher + * `@cldmv/fix-headers` uses, so all the header/quality tools agree on what is ignored. + * + * @module tools/lib/gitignore + */ + +import { readFileSync } from "node:fs"; +import { join, relative, sep } from "node:path"; +import ignore from "ignore"; + +/** + * Build a predicate that reports whether a path is excluded by `/.gitignore`. + * A missing `.gitignore` yields a predicate that ignores nothing. + * @param {string} rootDir - Absolute repository root (where `.gitignore` lives). + * @returns {(absPath: string, isDirectory?: boolean) => boolean} True when the path is gitignored. + * Pass `isDirectory: true` for a folder so directory-only patterns (`tmp/`) match it. + * @example + * const isIgnored = loadGitignore(rootDir); + * isIgnored(join(rootDir, "tmp"), true); // true + */ +export function loadGitignore(rootDir) { + let content; + try { + content = readFileSync(join(rootDir, ".gitignore"), "utf8"); + } catch { + return () => false; + } + const matcher = ignore().add(content); + return (absPath, isDirectory = false) => { + const rel = relative(rootDir, absPath).split(sep).join("/"); + // Paths outside the root (or the root itself) are never matched — `ignore` rejects them. + if (rel.length === 0 || rel.startsWith("..")) return false; + return matcher.ignores(isDirectory ? `${rel}/` : rel); + }; +} From b8c93214af567c72bfd019e0ca832b5c7afd898e Mon Sep 17 00:00:00 2001 From: Shinrai Date: Sun, 27 Sep 2026 23:26:15 -0700 Subject: [PATCH 05/49] fix(package): stop shipping the rolldown native binding to consumers @rolldown/binding-linux-x64-gnu was declared in optionalDependencies as a workaround for npm's optional-dependency lockfile bug (npm/cli#4828), so every consumer installed a 20 MB Linux bundler binary slothlet never uses. The lockfile already records every platform's binding as rolldown's own optional dependency, and a clean `npm ci` still installs the Linux binding and loads rolldown for vitest without the direct entry. Moving it to devDependencies instead would break `npm install` on non-Linux dev machines (EBADPLATFORM), so the entry is removed outright, along with the Dependabot ignore rule that only existed to pin it. Fixes #461 --- .github/dependabot.yml | 6 ------ package-lock.json | 5 ++--- package.json | 3 --- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6134ebd1a..e7ef847ae 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -67,12 +67,6 @@ updates: open-pull-requests-limit: 5 commit-message: prefix: "deps" - ignore: - # ABI-locked rolldown native binding (npm #4828 workaround): it must track the exact - # rolldown version vite ships (vite exact-pins rolldown, e.g. 1.0.3), not be bumped - # independently. The #149 bump to 1.1.0 had no rolldown consumer and shipped an unused - # ~21MB duplicate; keep this pinned in lockstep with vite's rolldown instead. - - dependency-name: "@rolldown/binding-linux-x64-gnu" groups: # vitest and @vitest/coverage-v8 (and other @vitest/* packages) peer # each other EXACTLY, so a partial bump (e.g. vitest to 5.0.0 while diff --git a/package-lock.json b/package-lock.json index ae5776303..11f816dd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,9 +48,6 @@ "type": "github", "url": "https://github.com/sponsors/shinrai" }, - "optionalDependencies": { - "@rolldown/binding-linux-x64-gnu": "1.1.3" - }, "peerDependencies": { "@cldmv/slothlet-i18n": "^3.11.0", "@cldmv/slothlet-types": "^3.11.0", @@ -1304,6 +1301,7 @@ "cpu": [ "x64" ], + "dev": true, "libc": [ "glibc" ], @@ -1312,6 +1310,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": "^20.19.0 || >=22.12.0" } diff --git a/package.json b/package.json index 3822180e3..721f56c38 100644 --- a/package.json +++ b/package.json @@ -342,9 +342,6 @@ "typescript": "^6.0.3", "vitest": "^5.0.0" }, - "optionalDependencies": { - "@rolldown/binding-linux-x64-gnu": "1.1.3" - }, "repository": { "type": "git", "url": "git+https://github.com/CLDMV/slothlet.git" From 99993042cbea3be71176a50a24f7778110e0fb91 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 28 Sep 2026 00:17:48 -0700 Subject: [PATCH 06/49] ci: measure the published JS files in the bundle-size workflow dist_paths was dist/**, which counted 12 i18n language JSON files (11 of them excluded from the package) and missed the published entry points index.mjs, index.cjs, devcheck.mjs and bin/slothlet.mjs. Match exactly the 62 JS files npm pack publishes, with a leading * on every pattern because the v4.29.2 measure action matches nothing for a bare top-level file and double-counts files across differently-rooted patterns. --- .github/workflows/bundle-size.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/bundle-size.yml b/.github/workflows/bundle-size.yml index e0ec52086..215ea0646 100644 --- a/.github/workflows/bundle-size.yml +++ b/.github/workflows/bundle-size.yml @@ -35,7 +35,8 @@ jobs: uses: CLDMV/.github/.github/workflows/reusable-bundle-size.yml@v4 with: build_command: "npm run build:ci" - dist_paths: "dist/**" + # Every pattern starts with `*` on purpose: the v4.29.2 measure action matches nothing for a bare top-level file and double-counts files when patterns have different roots. + dist_paths: "*.mjs,*.cjs,*dist/**/*.mjs,*bin/**" # warning_pct: 5 # warning_bytes: 500 # comment_mode: "update" From 9d9dca349e1c121d2ceb58a27cadebe6ad862303 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 28 Sep 2026 08:28:01 -0700 Subject: [PATCH 07/49] fix(unified-wrapper): land object/function writes through a live view on the underlying object A wrap-on-set view (`self.X = obj`, including one hung off an api.slothlet.api.add()-mounted leaf) forwarded primitive writes to the live impl but stored object- and function-valued writes on the wrapper itself. The underlying object never saw them, and the wrapper then shadowed that key, so later raw updates were invisible through the view. - setTrap: on a deferChildAdopt (wrap-on-set) wrapper, object/function writes now Reflect.set onto the live impl, drop any cached own property at that key, and propagate a refused write (frozen/sealed impl) as a TypeError, the same as the primitive path. - getTrap: on the same wrappers, an own property is treated as a cache of impl[prop] and dropped when the impl has moved on (a cached child wrapper fronting a replaced value, or a primitive accessor whose key now holds an object), so reads re-resolve and re-wrap from impl. Ordinary namespaces and EventEmitter-derived module exports keep the documented userAssigned wrap-on-set behaviour (O15). Docs state the two-way contract for all value types. Fixes #495 --- api_tests/api_test_live_view/store.mjs | 96 +++++ api_tests/api_test_live_view/untrusted.mjs | 30 ++ .../api_test_live_view_device/session.mjs | 81 ++++ docs/API-RULES/API-MUTATION-CONDITIONS.md | 3 +- docs/CONTEXT-PROPAGATION.md | 25 ++ src/lib/handlers/unified-wrapper.mjs | 33 ++ ...d-wrapper-live-view-writes.test.vitest.mjs | 361 ++++++++++++++++++ .../lib/handlers/unified-wrapper.d.mts.map | 2 +- 8 files changed, 629 insertions(+), 2 deletions(-) create mode 100644 api_tests/api_test_live_view/store.mjs create mode 100644 api_tests/api_test_live_view/untrusted.mjs create mode 100644 api_tests/api_test_live_view_device/session.mjs create mode 100644 tests/vitests/suites/handlers/unified-wrapper-live-view-writes.test.vitest.mjs diff --git a/api_tests/api_test_live_view/store.mjs b/api_tests/api_test_live_view/store.mjs new file mode 100644 index 000000000..b2218ffee --- /dev/null +++ b/api_tests/api_test_live_view/store.mjs @@ -0,0 +1,96 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_live_view/store.mjs + * @Date: 2026-09-28T06:11:59-07:00 (1790601119) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 06:11:59 -07:00 (1790601119) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Live-view write fixture (#495). Exposed as `self.store.*`. `assign` grafts a + * module-held object onto `self.store.x` via wrap-on-set; `features` and `dispatchRaw` read the + * module's own private reference (never the view), so a test can assert that an object- or + * function-valued write made THROUGH the view actually landed on the underlying object. + * @module api_test_live_view.store + * @memberof module:api_test_live_view + */ + +import { self } from "@cldmv/slothlet/runtime"; + +/** @type {object|null} The module's private reference to the object it published. */ +let held = null; + +/** + * Builds the shared live-view test shape. + * @returns {object} A fresh connection-like object. + */ +function buildShape() { + return { + n: 1, + deviceFeatures: [], + nested: { inner: {} }, + onUnhandledPacket: null, + /** + * Invokes the currently assigned `onUnhandledPacket` handler with `this` bound to the real object. + * @param {string} packet - Packet payload. + * @returns {unknown} The handler's result, or `"unhandled"` when none is set. + */ + dispatch(packet) { + return typeof this.onUnhandledPacket === "function" ? this.onUnhandledPacket(packet) : "unhandled"; + } + }; +} + +/** + * Builds a fresh object, keeps a private reference to it, and publishes it at `self.store.x`. + * @returns {object} The raw object that was assigned. + * @example + * const held = await api.store.assign(); + * api.store.x.deviceFeatures = ["cmd"]; + * held.deviceFeatures; // ["cmd"] + */ +export function assign() { + held = buildShape(); + self.store.x = held; + return held; +} + +/** + * Reads `deviceFeatures` from the module's own private reference. + * @returns {unknown} The current value of `held.deviceFeatures`. + */ +export function features() { + return held.deviceFeatures; +} + +/** + * Invokes `dispatch` on the module's own private reference. + * @param {string} packet - Packet payload. + * @returns {unknown} The handler's result. + */ +export function dispatchRaw(packet) { + return held.dispatch(packet); +} + +/** + * Writes `deviceFeatures` through the view from inside the owning module. + * @param {unknown} value - Value to assign. + * @returns {unknown} The value read back through the view. + */ +export function setFeaturesViaSelf(value) { + self.store.x.deviceFeatures = value; + return self.store.x.deviceFeatures; +} + +/** + * Reads `self.store.x.nested.inner.c.a` through the view from inside the owning module. + * @returns {unknown} The value served through the view. + */ +export function peek() { + return self.store.x.nested.inner.c.a; +} diff --git a/api_tests/api_test_live_view/untrusted.mjs b/api_tests/api_test_live_view/untrusted.mjs new file mode 100644 index 000000000..b16b75adc --- /dev/null +++ b/api_tests/api_test_live_view/untrusted.mjs @@ -0,0 +1,30 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_live_view/untrusted.mjs + * @Date: 2026-09-28T06:11:59-07:00 (1790601119) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 06:11:59 -07:00 (1790601119) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Caller-identity fixture for the live-view write tests (#495). A permission rule + * denies `untrusted.**` against `store.**`, so `peek` must be refused even for a value that was + * written through the view rather than present at assignment time. + * @module api_test_live_view.untrusted + * @memberof module:api_test_live_view + */ + +import { self } from "@cldmv/slothlet/runtime"; + +/** + * Reads `self.store.x.nested.inner.c.a` through the view. + * @returns {unknown} The value served through the view (when permitted). + */ +export function peek() { + return self.store.x.nested.inner.c.a; +} diff --git a/api_tests/api_test_live_view_device/session.mjs b/api_tests/api_test_live_view_device/session.mjs new file mode 100644 index 000000000..59addd27c --- /dev/null +++ b/api_tests/api_test_live_view_device/session.mjs @@ -0,0 +1,81 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_live_view_device/session.mjs + * @Date: 2026-09-28T06:11:59-07:00 (1790601119) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 06:11:59 -07:00 (1790601119) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Live-view write fixture for an `api.slothlet.api.add()`-mounted leaf (#495). + * Mounted with `api.slothlet.api.add("devices.d1", …)`, so it is reachable as + * `api.devices.d1.session.*`. `open` publishes a module-held connection object at + * `self.devices.d1.connection` (the droidsock `device.connection` pattern); `features` and + * `dispatchRaw` read the module's own private reference, never the view. + * @module api_test_live_view_device.session + * @memberof module:api_test_live_view_device + */ + +import { self } from "@cldmv/slothlet/runtime"; + +/** @type {object|null} The module's private reference to the published connection. */ +let connection = null; + +/** + * Opens a connection object, keeps a private reference, and publishes it on the mounted leaf. + * @returns {object} The raw connection object. + * @example + * const conn = await api.devices.d1.session.open(); + * api.devices.d1.connection.deviceFeatures = ["cmd"]; + * conn.deviceFeatures; // ["cmd"] + */ +export function open() { + connection = { + n: 1, + deviceFeatures: [], + nested: { inner: {} }, + onUnhandledPacket: null, + /** + * Invokes the currently assigned `onUnhandledPacket` handler with `this` bound to the real object. + * @param {string} packet - Packet payload. + * @returns {unknown} The handler's result, or `"unhandled"` when none is set. + */ + dispatch(packet) { + return typeof this.onUnhandledPacket === "function" ? this.onUnhandledPacket(packet) : "unhandled"; + } + }; + self.devices.d1.connection = connection; + return connection; +} + +/** + * Reads `deviceFeatures` from the module's own private connection reference. + * @returns {unknown} The current value of `connection.deviceFeatures`. + */ +export function features() { + return connection.deviceFeatures; +} + +/** + * Invokes `dispatch` on the module's own private connection reference. + * @param {string} packet - Packet payload. + * @returns {unknown} The handler's result. + */ +export function dispatchRaw(packet) { + return connection.dispatch(packet); +} + +/** + * Writes `deviceFeatures` through the view from inside the owning module. + * @param {unknown} value - Value to assign. + * @returns {unknown} The value read back through the view. + */ +export function setFeaturesViaSelf(value) { + self.devices.d1.connection.deviceFeatures = value; + return self.devices.d1.connection.deviceFeatures; +} diff --git a/docs/API-RULES/API-MUTATION-CONDITIONS.md b/docs/API-RULES/API-MUTATION-CONDITIONS.md index 4e76ac851..beb7faf89 100644 --- a/docs/API-RULES/API-MUTATION-CONDITIONS.md +++ b/docs/API-RULES/API-MUTATION-CONDITIONS.md @@ -53,10 +53,11 @@ if (next === undefined) { **Pattern**: A runtime top-level write wraps callable/object values through `UnifiedWrapper` (flagged `userAssigned`); primitives are stored raw. -**Source(s)**: `src/lib/handlers/api-manager.mjs:573-586` · `src/lib/handlers/unified-wrapper.mjs:4652-4699` (set-trap) +**Source(s)**: `src/lib/handlers/api-manager.mjs:573-586` · `src/lib/handlers/unified-wrapper.mjs:4789-4928` (set-trap) · `:3834-3850` (live-view read revalidation) **Trigger**: `typeof value === "function" || (value !== null && typeof value === "object")` **Result**: wrapped proxy stored (see O15 for reload survival); primitive stored directly. +**Live view writes**: the wrapped proxy is a two-way view onto the assigned object. A later write _through_ that view, of any value type (primitive, object, array or function), is applied to the underlying object with `Reflect.set` and served back wrapped from it on read. It is never stored on the view, so it cannot shadow a later raw update of the same key (#495). A write the underlying object refuses (frozen or sealed) throws `TypeError`. Only the top-level assignment onto a namespace is wrapped onto that namespace and tagged `userAssigned`. --- diff --git a/docs/CONTEXT-PROPAGATION.md b/docs/CONTEXT-PROPAGATION.md index 938068aab..ff43b996d 100644 --- a/docs/CONTEXT-PROPAGATION.md +++ b/docs/CONTEXT-PROPAGATION.md @@ -129,6 +129,31 @@ Two rules apply: - **Owner-scoped writes.** A module's writes are restricted to its own mount-point subtree. A module mounted at `api.lib.config` can write `self.lib.config.*` but not `self.lib.ssh.*` or any other top-level namespace. External code (no module-bound caller) and base-module code own the whole tree and can write anywhere. The error code on violation is `LOOSE_SET_NOT_OWNED`. - **Wrap-on-set for callables and objects.** When the assigned value is a function or object, it gets a `UnifiedWrapper` (the same wrapper construction `api.slothlet.api.add()` uses). Primitives stay as-is. **Limitation:** hook / permission / lifecycle integration on synthetic wrappers from `self.X = …` is incomplete — for fully lifecycle-integrated mounts, use `api.slothlet.api.add()`. +The wrapper is a **live, two-way view** onto the assigned object, not a copy. This holds wherever the assignment lands, including on a leaf mounted with `api.slothlet.api.add()`, and in both eager and lazy mode: + +- A raw write to the original object is visible through the view. +- A write through the view lands on the original object. This covers every value type: primitives, objects, arrays and functions, at any depth. The value is stored on the underlying object and served back wrapped from it on read. The view never keeps its own copy, so a later raw update of the same key is visible through the view too. + +```javascript +// Inside a module mounted at api.devices.d1: +const connection = { deviceFeatures: [], onUnhandledPacket: null }; +self.devices.d1.connection = connection; + +// Anywhere else: +api.devices.d1.connection.deviceFeatures = ["cmd"]; +connection.deviceFeatures; // ["cmd"] (the same array) + +api.devices.d1.connection.onUnhandledPacket = (packet) => handle(packet); +typeof connection.onUnhandledPacket; // "function" + +connection.deviceFeatures = ["later"]; +[...api.devices.d1.connection.deviceFeatures]; // ["later"] +``` + +The view is not identical to the object (`api.devices.d1.connection !== connection`), because it is a wrapper. `instanceof`, method calls (which run with the real object as `this`) and native objects such as `EventEmitter` and `net.Socket` all behave as they do on the original. Values read through the view are still read-gated by [permissions](PERMISSIONS.md), including values that were written through the view. A write that the underlying object refuses, for example because it is frozen, throws a `TypeError` just like a direct assignment in strict mode. + +This applies only to the assigned object and everything beneath it. Assigning a new key directly on an ordinary module namespace (`api.store.extra = {…}`) still stores a wrapped override on that namespace, as described above. + This was fixed in v3.5.0. Before that, `self.X = …` was silently dropped (proxy default-set onto an empty literal target). --- diff --git a/src/lib/handlers/unified-wrapper.mjs b/src/lib/handlers/unified-wrapper.mjs index 79ba73ae2..036fff544 100644 --- a/src/lib/handlers/unified-wrapper.mjs +++ b/src/lib/handlers/unified-wrapper.mjs @@ -3830,6 +3830,24 @@ export class UnifiedWrapper extends ComponentBase { // Return these children directly instead of creating waiting proxy. // No reserved-name re-check here: every framework name either returned from its own handler // above or was filtered by `isInternalProp`, so anything still in flight is a module member. + // + // On a wrap-on-set LIVE VIEW (`deferChildAdopt`) an own property is only a cache of + // `impl[prop]` (#495). Drop it when the underlying object has since moved on, so the read + // below re-resolves from impl instead of serving a stale child: a cached child wrapper + // fronting a value the key no longer holds, or a primitive live accessor whose key now holds + // an object (the accessor would hand that object back raw, unwrapped). + if (wrapper.____slothletInternal.deferChildAdopt && hasOwn(wrapper, prop)) { + const liveImpl = wrapper.____slothletInternal.impl; + if (liveImpl !== null && (typeof liveImpl === "object" || typeof liveImpl === "function") && prop in liveImpl) { + const cachedDesc = Object.getOwnPropertyDescriptor(wrapper, prop); + const current = liveImpl[prop]; + const stale = + typeof cachedDesc.get === "function" + ? current !== null && (typeof current === "object" || typeof current === "function") + : cachedDesc.value !== current && resolveWrapper(cachedDesc.value)?.____slothletInternal.impl !== current; + if (stale) delete wrapper[prop]; + } + } if (hasOwn(wrapper, prop)) { // CRITICAL: In replace mode, check if property should exist at all if (wrapper.____slothletInternal.state.collisionMode === "replace" && (prop === "power" || prop === "add")) { @@ -4837,6 +4855,21 @@ export class UnifiedWrapper extends ComponentBase { } return true; } + } else if (wrapper.____slothletInternal.deferChildAdopt) { + // Object/function write against a wrap-on-set LIVE VIEW (#495): land it on the live + // impl exactly like the primitive branch above, rather than wrap-on-set onto the + // wrapper. getTrap then serves it back lazily wrapped FROM impl, so the underlying + // object sees the write and the view never shadows a later raw update of the key. + // Any own property cached at this key (a child wrapper from an earlier read) is + // dropped so the next read re-resolves from impl. Scoped to `deferChildAdopt` only: + // an EventEmitter-derived module export is not a wrap-on-set view, and its + // object writes stay userAssigned wrapper overrides that survive reload (O15). + const liveImpl = wrapper.____slothletInternal.impl; + if (liveImpl !== null && (typeof liveImpl === "object" || typeof liveImpl === "function")) { + if (!Reflect.set(liveImpl, prop, value)) return false; + if (hasOwn(wrapper, prop)) delete wrapper[prop]; + return true; + } } // Delete property first if it exists (to allow reassignment) diff --git a/tests/vitests/suites/handlers/unified-wrapper-live-view-writes.test.vitest.mjs b/tests/vitests/suites/handlers/unified-wrapper-live-view-writes.test.vitest.mjs new file mode 100644 index 000000000..d9cebc6ee --- /dev/null +++ b/tests/vitests/suites/handlers/unified-wrapper-live-view-writes.test.vitest.mjs @@ -0,0 +1,361 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/handlers/unified-wrapper-live-view-writes.test.vitest.mjs + * @Date: 2026-09-28T06:11:59-07:00 (1790601119) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 06:11:59 -07:00 (1790601119) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Regression coverage for #495 — object- and function-valued writes through a live + * view (a wrap-on-set graft, including one hung off an `api.slothlet.api.add()`-mounted leaf) must + * land on the underlying object exactly like primitive writes already do (#340), be served back + * wrapped from that object on read, and never shadow later updates made to the underlying object. + * + * Every behavioural case runs through both routes and both modes: + * - `self.store.x = held` wrap-on-set from a base module (`api.store.x` is the view) + * - `self.devices.d1.connection = conn` from a module mounted via `api.slothlet.api.add("devices.d1", …)` + * (`api.devices.d1.connection` is the view — the droidsock `device.connection` case) + */ +import { describe, it, expect, afterEach } from "vitest"; +import { EventEmitter } from "node:events"; +import net from "node:net"; +import slothlet from "../../../../index.mjs"; +import { resolveWrapper } from "#handlers/unified-wrapper"; + +const BASE = new URL("../../../../api_tests/api_test_live_view", import.meta.url).pathname; +const DEVICE_DIR = new URL("../../../../api_tests/api_test_live_view_device", import.meta.url).pathname; + +/** + * Route descriptors. `setup` returns the raw object the module holds privately, a `view()` getter + * that re-reads the view through the api every time, and the module's own raw-reference readers. + */ +const ROUTES = { + "wrap-on-set (self.X = obj)": { + async setup(api) { + const raw = await api.store.assign(); + return { + raw, + view: () => api.store.x, + features: () => api.store.features(), + dispatchRaw: (packet) => api.store.dispatchRaw(packet), + setFeaturesViaSelf: (value) => api.store.setFeaturesViaSelf(value) + }; + } + }, + "api.add()-mounted leaf": { + async setup(api) { + await api.slothlet.api.add("devices.d1", DEVICE_DIR, { moduleID: "device-d1" }); + const raw = await api.devices.d1.session.open(); + return { + raw, + view: () => api.devices.d1.connection, + features: () => api.devices.d1.session.features(), + dispatchRaw: (packet) => api.devices.d1.session.dispatchRaw(packet), + setFeaturesViaSelf: (value) => api.devices.d1.session.setFeaturesViaSelf(value) + }; + } + } +}; + +describe.each(["eager", "lazy"])("UnifiedWrapper > live view object/function writes (#495) [%s]", (mode) => { + let api; + + afterEach(async () => { + if (api) await api.shutdown(); + api = null; + }); + + describe.each(Object.keys(ROUTES))("%s", (routeName) => { + const route = ROUTES[routeName]; + + const boot = async (extra = {}) => { + api = await slothlet({ base: BASE, mode, permissions: { defaultPolicy: "allow" }, ...extra }); + return route.setup(api); + }; + + it("an object write through the view reaches the underlying object", async () => { + const { raw, view, features } = await boot(); + const cmds = ["cmd"]; + + view().deviceFeatures = cmds; + + expect(raw.deviceFeatures).toBe(cmds); + expect(raw.deviceFeatures).toEqual(["cmd"]); + // The module's own method reading its private reference sees the write (droidsock deviceFeatures). + expect(await features()).toEqual(["cmd"]); + // Served back through the view, wrapped from the underlying object. + expect([...view().deviceFeatures]).toEqual(["cmd"]); + expect(view().deviceFeatures.length).toBe(1); + expect(resolveWrapper(view().deviceFeatures)).not.toBeNull(); + }); + + it("a write made from inside the owning module through self reaches the underlying object", async () => { + const { raw, setFeaturesViaSelf } = await boot(); + + const readBack = await setFeaturesViaSelf(["from-self"]); + + expect([...readBack]).toEqual(["from-self"]); + expect(raw.deviceFeatures).toEqual(["from-self"]); + }); + + it("a nested object write through the view reaches the underlying object", async () => { + const { raw, view } = await boot(); + + view().nested.inner.c = { a: 1 }; + + expect(raw.nested.inner.c).toEqual({ a: 1 }); + expect(view().nested.inner.c.a).toBe(1); + + // And a primitive write below the written object is still two-way. + view().nested.inner.c.a = 2; + expect(raw.nested.inner.c.a).toBe(2); + }); + + it("a function write through the view reaches the underlying object and is callable from either side", async () => { + const { raw, view, dispatchRaw } = await boot(); + const handler = (packet) => `got:${packet}`; + + view().onUnhandledPacket = handler; + + expect(raw.onUnhandledPacket).toBe(handler); + expect(raw.dispatch("a")).toBe("got:a"); + expect(await dispatchRaw("b")).toBe("got:b"); + expect(await view().onUnhandledPacket("c")).toBe("got:c"); + expect(await view().dispatch("d")).toBe("got:d"); + }); + + it("a later update of the key on the underlying object stays visible through the view (no shadowing)", async () => { + const { raw, view, features } = await boot(); + + view().deviceFeatures = ["cmd"]; + expect([...view().deviceFeatures]).toEqual(["cmd"]); + + raw.deviceFeatures = ["later"]; + expect([...view().deviceFeatures]).toEqual(["later"]); + expect(await features()).toEqual(["later"]); + + // A cached nested child read earlier is not served stale after the raw object replaces it. + expect(view().nested.inner).toBeDefined(); + raw.nested = { inner: { fresh: true } }; + expect(view().nested.inner.fresh).toBe(true); + + // A function replaced on the raw side is the one called through the view. + view().onUnhandledPacket = () => "first"; + expect(await view().onUnhandledPacket()).toBe("first"); + raw.onUnhandledPacket = () => "second"; + expect(await view().onUnhandledPacket()).toBe("second"); + }); + + it("a key can switch between primitive and object values in either direction", async () => { + const { raw, view } = await boot(); + + view().n = 10; + expect(raw.n).toBe(10); + raw.n = 11; + expect(view().n).toBe(11); + + // primitive → object through the view + view().n = { deep: 1 }; + expect(raw.n).toEqual({ deep: 1 }); + expect(view().n.deep).toBe(1); + expect(resolveWrapper(view().n)).not.toBeNull(); + + // object → primitive through the view + view().n = 12; + expect(raw.n).toBe(12); + expect(view().n).toBe(12); + + // primitive → object on the raw side, after the view has a live primitive accessor for the key + raw.n = { deep: 2 }; + expect(view().n.deep).toBe(2); + expect(resolveWrapper(view().n)).not.toBeNull(); + }); + + it("an EventEmitter written through the view stays the real instance and works through the view", async () => { + const { raw, view } = await boot(); + const emitter = new EventEmitter(); + + view().events = emitter; + + expect(raw.events).toBe(emitter); + expect(view().events instanceof EventEmitter).toBe(true); + let fired = null; + view().events.on("ping", (value) => { + fired = value; + }); + emitter.emit("ping", 7); + expect(fired).toBe(7); + }); + + it("a connected net.Socket written through the view keeps working native methods through the view", async () => { + const { raw, view } = await boot(); + // Echo server: replies only after the client writes, so the client's listeners (attached + // through the view) are guaranteed to be in place before any data arrives. + const server = net.createServer((conn) => { + conn.on("error", () => {}); + conn.on("data", (chunk) => conn.end(`echo:${chunk.toString()}`)); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const socket = new net.Socket(); + socket.on("error", () => {}); + try { + await new Promise((resolve, reject) => { + socket.once("error", reject); + socket.connect(server.address().port, "127.0.0.1", resolve); + }); + + view().socket = socket; + + expect(raw.socket).toBe(socket); + expect(view().socket instanceof net.Socket).toBe(true); + const received = await new Promise((resolve) => { + let data = ""; + view().socket.on("data", (chunk) => { + data += chunk.toString(); + }); + view().socket.on("end", () => resolve(data)); + // A native stream method called through the view (needs the real socket as `this`). + view().socket.write("ping"); + }); + expect(received).toBe("echo:ping"); + } finally { + socket.destroy(); + await new Promise((resolve) => server.close(resolve)); + } + }); + }); +}); + +describe("UnifiedWrapper > live view object/function writes (#495) — permissions, ownership, reload", () => { + let api; + + afterEach(async () => { + if (api) await api.shutdown(); + api = null; + }); + + it("read gating still applies to a value that was written through the view", async () => { + api = await slothlet({ + base: BASE, + mode: "eager", + permissions: { + defaultPolicy: "allow", + readGating: true, + rules: [{ caller: "untrusted.**", target: "store.**", effect: "deny" }] + } + }); + const raw = await api.store.assign(); + + api.store.x.nested.inner.c = { a: 42 }; + expect(raw.nested.inner.c).toEqual({ a: 42 }); + + // The owning module reads it back through the view. + expect(await api.store.peek()).toBe(42); + + // A caller denied against store.** is refused on the written value too. + await expect(async () => api.untrusted.peek()).rejects.toThrow(/PERMISSION_DENIED/); + }); + + it("an object write against a frozen underlying object throws instead of silently reporting success", async () => { + api = await slothlet({ base: BASE, mode: "eager", permissions: { defaultPolicy: "allow" } }); + const raw = await api.store.assign(); + const before = raw.deviceFeatures; + Object.freeze(raw); + + // Reflect.set fails (returns false) on the frozen object; the set trap must propagate that + // so the strict-mode assignment throws, exactly like the primitive-write path (#340). + expect(() => { + api.store.x.deviceFeatures = ["cmd"]; + }).toThrow(TypeError); + expect(raw.deviceFeatures).toBe(before); + }); + + it("a write through a stale view of a removed module does not reach the old underlying object", async () => { + api = await slothlet({ base: BASE, mode: "eager", permissions: { defaultPolicy: "allow" } }); + await api.slothlet.api.add("devices.d1", DEVICE_DIR, { moduleID: "device-d1" }); + const conn = await api.devices.d1.session.open(); + const staleView = api.devices.d1.connection; + + await api.slothlet.api.remove("device-d1"); + + // Removal invalidates the view and drops its live impl; a write through a retained stale + // reference must not silently mutate the object the removed module used to publish. + expect(resolveWrapper(staleView).____slothletInternal.impl).toBeNull(); + expect(() => { + staleView.deviceFeatures = ["late"]; + }).not.toThrow(); + expect(conn.deviceFeatures).toEqual([]); + expect(staleView.deviceFeatures).toBeUndefined(); + }); + + it("a child adopted onto the view by an internal eager re-adopt is still served after adoption moved it off impl", async () => { + api = await slothlet({ base: BASE, mode: "eager", permissions: { defaultPolicy: "allow" } }); + await api.store.assign(); + const xWrapper = resolveWrapper(api.store.x); + + // ___setImpl -> _applyNewImpl -> ___adoptImplChildren (the collision/reload reuse path) adopts + // `child` onto the wrapper and deletes it from the (cloned) impl. The live-view read + // revalidation must only drop a cached child when impl still HAS the key with a different + // value — never because adoption moved the key off impl. + xWrapper.___setImpl({ y: 1, child: { k: 1 } }, null, true); + expect("child" in xWrapper.____slothletInternal.impl).toBe(false); + expect(api.store.x.child.k).toBe(1); + }); + + it("an object write onto an ORDINARY (non-live) namespace still stores a userAssigned wrapper on the namespace", async () => { + api = await slothlet({ base: BASE, mode: "eager", permissions: { defaultPolicy: "allow" } }); + + const extra = { a: 1 }; + api.store.extra = extra; + + // Not a live view: the value is wrap-on-set onto the namespace wrapper, tagged userAssigned. + const extraWrapper = resolveWrapper(api.store.extra); + expect(extraWrapper).not.toBeNull(); + expect(extraWrapper.____slothletInternal.userAssigned).toBe(true); + expect(api.store.extra.a).toBe(1); + }); + + it("the view assigned on an api.add()-mounted leaf keeps its writes, and stays two-way, across a reload of that module", async () => { + api = await slothlet({ base: BASE, mode: "eager", permissions: { defaultPolicy: "allow" } }); + await api.slothlet.api.add("devices.d1", DEVICE_DIR, { moduleID: "device-d1" }); + const conn = await api.devices.d1.session.open(); + + api.devices.d1.connection.deviceFeatures = ["cmd"]; + const handler = () => "handled"; + api.devices.d1.connection.onUnhandledPacket = handler; + + await api.slothlet.api.reload("device-d1"); + + // The userAssigned `connection` view survives the reload (O15) and still fronts the same object. + expect(resolveWrapper(api.devices.d1.connection).____slothletInternal.userAssigned).toBe(true); + expect([...api.devices.d1.connection.deviceFeatures]).toEqual(["cmd"]); + expect(await api.devices.d1.connection.onUnhandledPacket()).toBe("handled"); + expect(conn.deviceFeatures).toEqual(["cmd"]); + + // Still two-way after the reload. + api.devices.d1.connection.deviceFeatures = ["after-reload"]; + expect(conn.deviceFeatures).toEqual(["after-reload"]); + conn.deviceFeatures = ["raw-after-reload"]; + expect([...api.devices.d1.connection.deviceFeatures]).toEqual(["raw-after-reload"]); + }); + + it("the wrap-on-set view keeps its writes across a selective reload of its owning module", async () => { + api = await slothlet({ base: BASE, mode: "eager", permissions: { defaultPolicy: "allow" } }); + const raw = await api.store.assign(); + + api.store.x.deviceFeatures = ["cmd"]; + + await api.slothlet.api.reload("store"); + + expect(resolveWrapper(api.store.x).____slothletInternal.userAssigned).toBe(true); + expect([...api.store.x.deviceFeatures]).toEqual(["cmd"]); + api.store.x.deviceFeatures = ["after-reload"]; + expect(raw.deviceFeatures).toEqual(["after-reload"]); + }); +}); diff --git a/types/src/lib/handlers/unified-wrapper.d.mts.map b/types/src/lib/handlers/unified-wrapper.d.mts.map index 18cdec8b7..3dd243a01 100644 --- a/types/src/lib/handlers/unified-wrapper.d.mts.map +++ b/types/src/lib/handlers/unified-wrapper.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"unified-wrapper.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/unified-wrapper.mjs"],"names":[],"mappings":"AA0xBA;;;;;;;;;;;;;;;;;;GAkBG;AACH,4CAhBW,MAAM,GAAC,MAAM,GACX,OAAO,CAkBnB;AA+vID;;;;;;;;;;;GAWG;AACH,sCAPW,OAAO,GACL,cAAc,GAAC,IAAI,CAkB/B;AAxzID;;;;;;;;GAQG;AACH,iCAHU,GAAG,CAAC,MAAM,CAAC,CAG2F;;;;;AAuEhH;;;;;;;;;;GAUG;AACH;IAiTC;;;;;;;;;;;;;;;;OAgBG;IACH,0BAsCC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,gCAiEC;IAnaD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAiCG;IACH,sBAjCW,MAAM,uJAEd;QAAwB,IAAI,EAApB,MAAM;QACU,OAAO,EAAvB,MAAM;QACyB,WAAW;QACvB,eAAe;QAChB,UAAU;QACV,mBAAmB;QACpB,QAAQ;QACR,QAAQ;QACR,YAAY;QACE,cAAc;QAI3B,eAAe;KAMzC,EA4MF;IAnPD;;;;;;;;;;;OAWG;IACH,4BAFa,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAC,SAAS,CAKzC;IAsOD;;;;;;;;;;OAUG;IACH,uEAHa,GAAC,CA+Bb;IAED;;;;OAIG;IACH,qBAHa,WAAS,MAAM,GAAC,IAAI,CAKhC;IAiJD;;;;;;;;;;;OAWG;IACH,sBAiCC;IAED;;;;;;;;;;;;OAYG;IACH,mBAgDC;IAED;;;;;;;;;OASG;IACH,qBAsDC;IAED;;;;OAIG;IACH,uBA4GC;IAED;;;;;;;;;OASG;IACH,qBAEC;IAED;;;;;;;;;OASG;IACH,sBAiBC;IAED;;;;;;;;;;OAUG;IACH,6BA4gBC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BAgOC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BA43BC;IAED;;;;;;OAMG;IACH,uCAmwDC;;CACD;8BApgK6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"unified-wrapper.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/unified-wrapper.mjs"],"names":[],"mappings":"AA0xBA;;;;;;;;;;;;;;;;;;GAkBG;AACH,4CAhBW,MAAM,GAAC,MAAM,GACX,OAAO,CAkBnB;AAgyID;;;;;;;;;;;GAWG;AACH,sCAPW,OAAO,GACL,cAAc,GAAC,IAAI,CAkB/B;AAz1ID;;;;;;;;GAQG;AACH,iCAHU,GAAG,CAAC,MAAM,CAAC,CAG2F;;;;;AAuEhH;;;;;;;;;;GAUG;AACH;IAiTC;;;;;;;;;;;;;;;;OAgBG;IACH,0BAsCC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,gCAiEC;IAnaD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAiCG;IACH,sBAjCW,MAAM,uJAEd;QAAwB,IAAI,EAApB,MAAM;QACU,OAAO,EAAvB,MAAM;QACyB,WAAW;QACvB,eAAe;QAChB,UAAU;QACV,mBAAmB;QACpB,QAAQ;QACR,QAAQ;QACR,YAAY;QACE,cAAc;QAI3B,eAAe;KAMzC,EA4MF;IAnPD;;;;;;;;;;;OAWG;IACH,4BAFa,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAC,SAAS,CAKzC;IAsOD;;;;;;;;;;OAUG;IACH,uEAHa,GAAC,CA+Bb;IAED;;;;OAIG;IACH,qBAHa,WAAS,MAAM,GAAC,IAAI,CAKhC;IAiJD;;;;;;;;;;;OAWG;IACH,sBAiCC;IAED;;;;;;;;;;;;OAYG;IACH,mBAgDC;IAED;;;;;;;;;OASG;IACH,qBAsDC;IAED;;;;OAIG;IACH,uBA4GC;IAED;;;;;;;;;OASG;IACH,qBAEC;IAED;;;;;;;;;OASG;IACH,sBAiBC;IAED;;;;;;;;;;OAUG;IACH,6BA4gBC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BAgOC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BA43BC;IAED;;;;;;OAMG;IACH,uCAoyDC;;CACD;8BAriK6B,2BAA2B"} \ No newline at end of file From 0a5e031e7b3c4e51250a5844418b2f36aaefc1e3 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 28 Sep 2026 10:08:18 -0700 Subject: [PATCH 08/49] fix(typescript): ship strict mode's type-generation worker in the package `typescript: { mode: "strict" }` forks a worker to generate the api's declaration file, but the worker lived in tools/build/ and imported ../../src/..., and neither tools/ nor src/ is in package.json `files`. Strict mode could only work inside this repository; from an npm install it failed with MODULE_NOT_FOUND for tools/build/generate-types-worker.mjs. The worker now lives in src/lib/processors/type-generation-worker.mjs, ships as dist/lib/processors/, and imports only @cldmv/slothlet self-references, so it resolves to src/ under the slothlet-dev condition and to dist/ when installed. The loader resolves it with typeGenerationWorkerPath() on the Node-only strict path (path/url are null in browser mode, and the literal new URL("./x", import.meta.url) form makes vite load the worker into the current process). The worker's logic is exported (runTypeGeneration, runIfEntry) and only runs when the file is the process's entry script, so importing it never starts a build or exits the importer, and tests drive it in-process. A missing SLOTHLET_CONFIG raises TS_TYPE_GENERATION_WORKER_NO_CONFIG. Fixes #500 --- src/lib/i18n/languages/de-de.json | 4 +- src/lib/i18n/languages/en-gb.json | 4 +- src/lib/i18n/languages/en-us.json | 4 +- src/lib/i18n/languages/es-es.json | 4 +- src/lib/i18n/languages/es-mx.json | 4 +- src/lib/i18n/languages/fr-fr.json | 4 +- src/lib/i18n/languages/hi-in.json | 4 +- src/lib/i18n/languages/ja-jp.json | 6 +- src/lib/i18n/languages/ko-kr.json | 4 +- src/lib/i18n/languages/pt-br.json | 4 +- src/lib/i18n/languages/ru-ru.json | 4 +- src/lib/i18n/languages/zh-cn.json | 4 +- src/lib/processors/loader.mjs | 22 ++- src/lib/processors/type-generation-worker.mjs | 85 ++++++++++ ...cript-strict-mode-packaged.test.vitest.mjs | 154 ++++++++++++++++++ tools/build/generate-types-worker.mjs | 69 -------- tools/ci/i18n-latin-tokens-accepted.json | 19 ++- types/src/lib/processors/loader.d.mts | 12 ++ types/src/lib/processors/loader.d.mts.map | 2 +- .../processors/type-generation-worker.d.mts | 31 ++++ .../type-generation-worker.d.mts.map | 1 + 21 files changed, 356 insertions(+), 89 deletions(-) create mode 100644 src/lib/processors/type-generation-worker.mjs create mode 100644 tests/vitests/suites/typescript/typescript-strict-mode-packaged.test.vitest.mjs delete mode 100644 tools/build/generate-types-worker.mjs create mode 100644 types/src/lib/processors/type-generation-worker.d.mts create mode 100644 types/src/lib/processors/type-generation-worker.d.mts.map diff --git a/src/lib/i18n/languages/de-de.json b/src/lib/i18n/languages/de-de.json index 7df022983..929bd3f54 100644 --- a/src/lib/i18n/languages/de-de.json +++ b/src/lib/i18n/languages/de-de.json @@ -353,7 +353,9 @@ "TS_TYPE_GENERATION_FAILED": "Typ-Generierung fehlgeschlagen: {error}", "HINT_TS_TYPE_GENERATION_FAILED": "Der Kindprozess, der die TypeScript-Deklarationen generiert, ist auf einen Fehler gestoßen. Prüfen Sie die Fehlermeldung auf Details und stellen Sie sicher, dass die TypeScript-Quelldateien gültig sind.", "TS_TYPE_GENERATION_FORK_FAILED": "Fehler beim Forken des Typ-Generierungs-Prozesses: {error}", - "HINT_TS_TYPE_GENERATION_FORK_FAILED": "Der Worker-Prozess zur Typ-Generierung konnte nicht gestartet werden. Stellen Sie sicher, dass Node.js Berechtigungen zum Forken von Kindprozessen hat und das Script tools/build/generate-types-worker.mjs existiert.", + "HINT_TS_TYPE_GENERATION_FORK_FAILED": "Der Worker-Prozess zur Typ-Generierung konnte nicht gestartet werden. Stellen Sie sicher, dass Node.js Berechtigungen zum Forken von Kindprozessen hat und das Script lib/processors/type-generation-worker.mjs existiert.", + "TS_TYPE_GENERATION_WORKER_NO_CONFIG": "Der Worker zur Typ-Generierung wurde ohne seine Konfiguration gestartet (SLOTHLET_CONFIG ist nicht gesetzt).", + "HINT_TS_TYPE_GENERATION_WORKER_NO_CONFIG": "Dieser Worker wird vom strikten TypeScript-Modus mit seiner Konfiguration in der Umgebungsvariable SLOTHLET_CONFIG gestartet. Lassen Sie slothlet ihn forken, statt die Datei direkt auszuführen.", "TS_TYPE_GENERATION_PROCESS_EXITED": "Prozess zur Typ-Generierung mit Code {code} beendet: {output}", "HINT_TS_TYPE_GENERATION_PROCESS_EXITED": "Der Kindprozess zur Typ-Generierung wurde mit einem Nicht-Null-Code beendet. Prüfen Sie die Ausgabe auf Compiler-Fehler und stellen Sie sicher, dass die TypeScript-Quellen gültig sind.", "TS_TYPE_CHECK_ERRORS": "TypeScript-Typfehler in '{filePath}' gefunden:\n{errors}", diff --git a/src/lib/i18n/languages/en-gb.json b/src/lib/i18n/languages/en-gb.json index 69a6f5193..1c491eefe 100644 --- a/src/lib/i18n/languages/en-gb.json +++ b/src/lib/i18n/languages/en-gb.json @@ -353,7 +353,9 @@ "TS_TYPE_GENERATION_FAILED": "Type generation failed: {error}", "HINT_TS_TYPE_GENERATION_FAILED": "The child process that generates TypeScript declarations encountered an error. Check the error message for details and ensure the TypeScript source files are valid.", "TS_TYPE_GENERATION_FORK_FAILED": "Failed to fork the type generation process: {error}", - "HINT_TS_TYPE_GENERATION_FORK_FAILED": "The type generation worker process could not be started. Ensure Node.js has permission to fork child processes and that the tools/build/generate-types-worker.mjs script exists.", + "HINT_TS_TYPE_GENERATION_FORK_FAILED": "The type generation worker process could not be started. Ensure Node.js has permission to fork child processes and that the lib/processors/type-generation-worker.mjs script exists.", + "TS_TYPE_GENERATION_WORKER_NO_CONFIG": "The type generation worker was started without its configuration (SLOTHLET_CONFIG is not set).", + "HINT_TS_TYPE_GENERATION_WORKER_NO_CONFIG": "This worker is started by strict TypeScript mode with its configuration in the SLOTHLET_CONFIG environment variable. Let slothlet fork it rather than running the file directly.", "TS_TYPE_GENERATION_PROCESS_EXITED": "Type generation process exited with code {code}: {output}", "HINT_TS_TYPE_GENERATION_PROCESS_EXITED": "The type generation child process exited with a non-zero code. Check the output for compiler errors and verify the TypeScript sources are valid.", "TS_TYPE_CHECK_ERRORS": "TypeScript type errors found in '{filePath}':\n{errors}", diff --git a/src/lib/i18n/languages/en-us.json b/src/lib/i18n/languages/en-us.json index ee411e401..3abf3df9c 100644 --- a/src/lib/i18n/languages/en-us.json +++ b/src/lib/i18n/languages/en-us.json @@ -353,7 +353,9 @@ "TS_TYPE_GENERATION_FAILED": "Type generation failed: {error}", "HINT_TS_TYPE_GENERATION_FAILED": "The child process that generates TypeScript declarations encountered an error. Check the error message for details and ensure the TypeScript source files are valid.", "TS_TYPE_GENERATION_FORK_FAILED": "Failed to fork the type generation process: {error}", - "HINT_TS_TYPE_GENERATION_FORK_FAILED": "The type generation worker process could not be started. Ensure Node.js has permission to fork child processes and that the tools/build/generate-types-worker.mjs script exists.", + "HINT_TS_TYPE_GENERATION_FORK_FAILED": "The type generation worker process could not be started. Ensure Node.js has permission to fork child processes and that the lib/processors/type-generation-worker.mjs script exists.", + "TS_TYPE_GENERATION_WORKER_NO_CONFIG": "The type generation worker was started without its configuration (SLOTHLET_CONFIG is not set).", + "HINT_TS_TYPE_GENERATION_WORKER_NO_CONFIG": "This worker is started by strict TypeScript mode with its configuration in the SLOTHLET_CONFIG environment variable. Let slothlet fork it rather than running the file directly.", "TS_TYPE_GENERATION_PROCESS_EXITED": "Type generation process exited with code {code}: {output}", "HINT_TS_TYPE_GENERATION_PROCESS_EXITED": "The type generation child process exited with a non-zero code. Check the output for compiler errors and verify the TypeScript sources are valid.", "TS_TYPE_CHECK_ERRORS": "TypeScript type errors found in '{filePath}':\n{errors}", diff --git a/src/lib/i18n/languages/es-es.json b/src/lib/i18n/languages/es-es.json index 1c53dc7f7..e66ad48e4 100644 --- a/src/lib/i18n/languages/es-es.json +++ b/src/lib/i18n/languages/es-es.json @@ -353,7 +353,9 @@ "TS_TYPE_GENERATION_FAILED": "La generación de tipos falló: {error}", "HINT_TS_TYPE_GENERATION_FAILED": "El proceso hijo que genera las declaraciones de TypeScript encontró un error. Comprueba el mensaje de error para más detalles y asegúrate de que los ficheros fuente de TypeScript son válidos.", "TS_TYPE_GENERATION_FORK_FAILED": "Error al bifurcar (fork) el proceso de generación de tipos: {error}", - "HINT_TS_TYPE_GENERATION_FORK_FAILED": "El proceso trabajador de generación de tipos no pudo iniciarse. Asegúrate de que Node.js tiene permiso para bifurcar procesos hijos y que el script tools/build/generate-types-worker.mjs existe.", + "HINT_TS_TYPE_GENERATION_FORK_FAILED": "El proceso trabajador de generación de tipos no pudo iniciarse. Asegúrate de que Node.js tiene permiso para bifurcar procesos hijos y que el script lib/processors/type-generation-worker.mjs existe.", + "TS_TYPE_GENERATION_WORKER_NO_CONFIG": "El trabajador de generación de tipos se inició sin su configuración (SLOTHLET_CONFIG no está definida).", + "HINT_TS_TYPE_GENERATION_WORKER_NO_CONFIG": "El modo estricto de TypeScript inicia este trabajador con su configuración en la variable de entorno SLOTHLET_CONFIG. Deja que slothlet lo bifurque en lugar de ejecutar el archivo directamente.", "TS_TYPE_GENERATION_PROCESS_EXITED": "El proceso de generación de tipos terminó con el código {code}: {output}", "HINT_TS_TYPE_GENERATION_PROCESS_EXITED": "El proceso hijo de generación de tipos terminó con un código distinto de cero. Comprueba la salida en busca de errores del compilador y verifica que las fuentes de TypeScript son válidas.", "TS_TYPE_CHECK_ERRORS": "Errores de tipo TypeScript encontrados en '{filePath}':\n{errors}", diff --git a/src/lib/i18n/languages/es-mx.json b/src/lib/i18n/languages/es-mx.json index 3d5a215ea..ebc73f8e7 100644 --- a/src/lib/i18n/languages/es-mx.json +++ b/src/lib/i18n/languages/es-mx.json @@ -353,7 +353,9 @@ "TS_TYPE_GENERATION_FAILED": "La generación de tipos falló: {error}", "HINT_TS_TYPE_GENERATION_FAILED": "El proceso hijo que genera las declaraciones de TypeScript encontró un error. Compruebe el mensaje de error para más detalles y asegúrese de que los archivos fuente de TypeScript son válidos.", "TS_TYPE_GENERATION_FORK_FAILED": "Error al bifurcar (fork) el proceso de generación de tipos: {error}", - "HINT_TS_TYPE_GENERATION_FORK_FAILED": "El proceso trabajador de generación de tipos no pudo iniciarse. Asegúrese de que Node.js tiene permiso para bifurcar procesos hijos y que el script tools/build/generate-types-worker.mjs existe.", + "HINT_TS_TYPE_GENERATION_FORK_FAILED": "El proceso trabajador de generación de tipos no pudo iniciarse. Asegúrese de que Node.js tiene permiso para bifurcar procesos hijos y que el script lib/processors/type-generation-worker.mjs existe.", + "TS_TYPE_GENERATION_WORKER_NO_CONFIG": "El trabajador de generación de tipos se inició sin su configuración (SLOTHLET_CONFIG no está definida).", + "HINT_TS_TYPE_GENERATION_WORKER_NO_CONFIG": "El modo estricto de TypeScript inicia este trabajador con su configuración en la variable de entorno SLOTHLET_CONFIG. Deje que slothlet lo bifurque en lugar de ejecutar el archivo directamente.", "TS_TYPE_GENERATION_PROCESS_EXITED": "El proceso de generación de tipos terminó con el código {code}: {output}", "HINT_TS_TYPE_GENERATION_PROCESS_EXITED": "El proceso hijo de generación de tipos terminó con un código distinto de cero. Compruebe la salida en busca de errores del compilador y verifique que las fuentes de TypeScript son válidas.", "TS_TYPE_CHECK_ERRORS": "Errores de tipo TypeScript encontrados en '{filePath}':\n{errors}", diff --git a/src/lib/i18n/languages/fr-fr.json b/src/lib/i18n/languages/fr-fr.json index fe61708ee..7703c24a9 100644 --- a/src/lib/i18n/languages/fr-fr.json +++ b/src/lib/i18n/languages/fr-fr.json @@ -353,7 +353,9 @@ "TS_TYPE_GENERATION_FAILED": "La génération de types a échoué : {error}", "HINT_TS_TYPE_GENERATION_FAILED": "Le processus enfant qui génère les déclarations TypeScript a rencontré une erreur. Vérifiez le message d'erreur pour les détails et assurez-vous que les fichiers sources TypeScript sont valides.", "TS_TYPE_GENERATION_FORK_FAILED": "Échec du fork du processus de génération de types : {error}", - "HINT_TS_TYPE_GENERATION_FORK_FAILED": "Le processus travailleur de génération de types n'a pas pu être démarré. Assurez-vous que Node.js a la permission de forker des processus enfants et que le script tools/build/generate-types-worker.mjs existe.", + "HINT_TS_TYPE_GENERATION_FORK_FAILED": "Le processus travailleur de génération de types n'a pas pu être démarré. Assurez-vous que Node.js a la permission de forker des processus enfants et que le script lib/processors/type-generation-worker.mjs existe.", + "TS_TYPE_GENERATION_WORKER_NO_CONFIG": "Le travailleur de génération de types a été démarré sans sa configuration (SLOTHLET_CONFIG n'est pas définie).", + "HINT_TS_TYPE_GENERATION_WORKER_NO_CONFIG": "Ce travailleur est démarré par le mode TypeScript strict avec sa configuration dans la variable d'environnement SLOTHLET_CONFIG. Laissez slothlet le forker plutôt que d'exécuter le fichier directement.", "TS_TYPE_GENERATION_PROCESS_EXITED": "Le processus de génération de types s'est arrêté avec le code {code} : {output}", "HINT_TS_TYPE_GENERATION_PROCESS_EXITED": "Le processus enfant de génération de types s'est arrêté avec un code non nul. Vérifiez la sortie pour les erreurs de compilation et vérifiez que les sources TypeScript sont valides.", "TS_TYPE_CHECK_ERRORS": "Erreurs de type TypeScript trouvées dans '{filePath}' :\n{errors}", diff --git a/src/lib/i18n/languages/hi-in.json b/src/lib/i18n/languages/hi-in.json index 7d55ab82b..9767e62cb 100644 --- a/src/lib/i18n/languages/hi-in.json +++ b/src/lib/i18n/languages/hi-in.json @@ -353,7 +353,9 @@ "TS_TYPE_GENERATION_FAILED": "प्रकार पीढ़ी विफल: {error}", "HINT_TS_TYPE_GENERATION_FAILED": "टाइपस्क्रिप्ट घोषणाएं उत्पन्न करने वाली चाइल्ड प्रोसेस में त्रुटि हुई। विवरण के लिए त्रुटि संदेश की जांच करें और सुनिश्चित करें कि टाइपस्क्रिप्ट स्रोत फ़ाइलें मान्य हैं।", "TS_TYPE_GENERATION_FORK_FAILED": "प्रकार पीढ़ी प्रोसेस को फोर्क (fork) करने में विफल: {error}", - "HINT_TS_TYPE_GENERATION_FORK_FAILED": "प्रकार पीढ़ी वर्कर प्रोसेस शुरू नहीं किया जा सका। सुनिश्चित करें कि Node.js के पास चाइल्ड प्रोसेस फोर्क करने की अनुमति है और tools/build/generate-types-worker.mjs स्क्रिप्ट मौजूद है।", + "HINT_TS_TYPE_GENERATION_FORK_FAILED": "प्रकार पीढ़ी वर्कर प्रोसेस शुरू नहीं किया जा सका। सुनिश्चित करें कि Node.js के पास चाइल्ड प्रोसेस फोर्क करने की अनुमति है और lib/processors/type-generation-worker.mjs स्क्रिप्ट मौजूद है।", + "TS_TYPE_GENERATION_WORKER_NO_CONFIG": "प्रकार पीढ़ी वर्कर अपने कॉन्फ़िगरेशन के बिना शुरू किया गया (SLOTHLET_CONFIG सेट नहीं है)।", + "HINT_TS_TYPE_GENERATION_WORKER_NO_CONFIG": "यह वर्कर TypeScript strict मोड द्वारा SLOTHLET_CONFIG पर्यावरण चर में कॉन्फ़िगरेशन के साथ शुरू किया जाता है। फ़ाइल को सीधे चलाने के बजाय slothlet को इसे फोर्क करने दें।", "TS_TYPE_GENERATION_PROCESS_EXITED": "प्रकार पीढ़ी प्रोसेस कोड {code} के साथ बाहर निकला: {output}", "HINT_TS_TYPE_GENERATION_PROCESS_EXITED": "प्रकार पीढ़ी चाइल्ड प्रोसेस गैर-शून्य कोड के साथ समाप्त हुई। कंपाइलर त्रुटियों के लिए आउटपुट की जांच करें और सत्यापित करें कि टाइपस्क्रिप्ट स्रोत मान्य हैं।", "TS_TYPE_CHECK_ERRORS": "'{filePath}' में टाइपस्क्रिप्ट प्रकार त्रुटियाँ मिलीं:\n{errors}", diff --git a/src/lib/i18n/languages/ja-jp.json b/src/lib/i18n/languages/ja-jp.json index 68b4d80f7..31180ee05 100644 --- a/src/lib/i18n/languages/ja-jp.json +++ b/src/lib/i18n/languages/ja-jp.json @@ -352,8 +352,10 @@ "HINT_TS_STRICT_REQUIRES_INTERFACE_NAME": "'types' 構成に 'interfaceName' を追加してください (例: types: { output: './types/api.d.ts', interfaceName: 'MyAPI' })。", "TS_TYPE_GENERATION_FAILED": "型生成に失敗しました: {error}", "HINT_TS_TYPE_GENERATION_FAILED": "TypeScript 宣言を生成する子プロセスでエラーが発生しました。エラーメッセージで詳細を確認し、TypeScript ソースファイルが有効であることを確認してください。", - "TS_TYPE_GENERATION_FORK_FAILED": "型生成ワーカープロセスを開始できませんでした。 Node.js に子プロセスをフォークする権限があること、および tools/build/generate-types-worker.mjs スクリプトが存在することを確認してください。", - "HINT_TS_TYPE_GENERATION_FORK_FAILED": "Node.js がプロセスをフォークできること、および tools/build/generate-types-worker.mjs が存在することを確認してください。", + "TS_TYPE_GENERATION_FORK_FAILED": "型生成ワーカープロセスを開始できませんでした。 Node.js に子プロセスをフォークする権限があること、および lib/processors/type-generation-worker.mjs スクリプトが存在することを確認してください。", + "HINT_TS_TYPE_GENERATION_FORK_FAILED": "Node.js がプロセスをフォークできること、および lib/processors/type-generation-worker.mjs が存在することを確認してください。", + "TS_TYPE_GENERATION_WORKER_NO_CONFIG": "型生成ワーカーが設定なしで起動されました(SLOTHLET_CONFIG が設定されていません)。", + "HINT_TS_TYPE_GENERATION_WORKER_NO_CONFIG": "このワーカーは TypeScript の strict モードが SLOTHLET_CONFIG 環境変数に設定を入れて起動します。ファイルを直接実行せず、slothlet にフォークさせてください。", "TS_TYPE_GENERATION_PROCESS_EXITED": "型生成プロセスがコード {code} で終了しました: {output}", "HINT_TS_TYPE_GENERATION_PROCESS_EXITED": "型生成子プロセスがゼロ以外のコードで終了しました。コンパイラエラーがないか出力を確認し、TypeScript ソースが有効であることを検証してください。", "TS_TYPE_CHECK_ERRORS": "'{filePath}' で TypeScript の型エラーが見つかりました:\n{errors}", diff --git a/src/lib/i18n/languages/ko-kr.json b/src/lib/i18n/languages/ko-kr.json index 65938385b..2c03edd90 100644 --- a/src/lib/i18n/languages/ko-kr.json +++ b/src/lib/i18n/languages/ko-kr.json @@ -353,7 +353,9 @@ "TS_TYPE_GENERATION_FAILED": "타입 생성 실패: {error}", "HINT_TS_TYPE_GENERATION_FAILED": "TypeScript 선언을 생성하는 하위 프로세스에서 오류가 발생했습니다. 오류 메시지에서 세부 정보를 확인하고 TypeScript 소스 파일이 유효한지 확인하십시오.", "TS_TYPE_GENERATION_FORK_FAILED": "타입 생성 프로세스 분기(fork) 실패: {error}", - "HINT_TS_TYPE_GENERATION_FORK_FAILED": "타입 생성 워커 프로세스를 시작할 수 없습니다. Node.js에 하위 프로세스 분기 권한이 있는지, tools/build/generate-types-worker.mjs 스크립트가 존재하는지 확인하십시오.", + "HINT_TS_TYPE_GENERATION_FORK_FAILED": "타입 생성 워커 프로세스를 시작할 수 없습니다. Node.js에 하위 프로세스 분기 권한이 있는지, lib/processors/type-generation-worker.mjs 스크립트가 존재하는지 확인하십시오.", + "TS_TYPE_GENERATION_WORKER_NO_CONFIG": "타입 생성 워커가 구성 없이 시작되었습니다 (SLOTHLET_CONFIG가 설정되지 않음).", + "HINT_TS_TYPE_GENERATION_WORKER_NO_CONFIG": "이 워커는 TypeScript strict 모드가 SLOTHLET_CONFIG 환경 변수에 구성을 담아 시작합니다. 파일을 직접 실행하지 말고 slothlet이 포크하도록 하십시오.", "TS_TYPE_GENERATION_PROCESS_EXITED": "타입 생성 프로세스가 종료 코드 {code}로 종료됨: {output}", "HINT_TS_TYPE_GENERATION_PROCESS_EXITED": "타입 생성 하위 프로세스가 0이 아닌 코드로 종료되었습니다. 컴파일러 오류가 있는지 출력을 확인하고 TypeScript 소스가 유효한지 확인하십시오.", "TS_TYPE_CHECK_ERRORS": "'{filePath}'에서 TypeScript 타입 오류 발견:\n{errors}", diff --git a/src/lib/i18n/languages/pt-br.json b/src/lib/i18n/languages/pt-br.json index d0bf1e936..9589f562c 100644 --- a/src/lib/i18n/languages/pt-br.json +++ b/src/lib/i18n/languages/pt-br.json @@ -353,7 +353,9 @@ "TS_TYPE_GENERATION_FAILED": "Falha na geração de tipos: {error}", "HINT_TS_TYPE_GENERATION_FAILED": "O processo filho que gera as declarações do TypeScript encontrou um erro. Verifique a mensagem de erro para obter detalhes e certifique-se de que os arquivos de origem do TypeScript são válidos.", "TS_TYPE_GENERATION_FORK_FAILED": "Falha ao criar (fork) o processo de geração de tipos: {error}", - "HINT_TS_TYPE_GENERATION_FORK_FAILED": "O processo de execução da geração de tipos não pôde ser iniciado. Certifique-se de que o Node.js tenha permissão para criar processos filhos e que o script tools/build/generate-types-worker.mjs exista.", + "HINT_TS_TYPE_GENERATION_FORK_FAILED": "O processo de execução da geração de tipos não pôde ser iniciado. Certifique-se de que o Node.js tenha permissão para criar processos filhos e que o script lib/processors/type-generation-worker.mjs exista.", + "TS_TYPE_GENERATION_WORKER_NO_CONFIG": "O worker de geração de tipos foi iniciado sem sua configuração (SLOTHLET_CONFIG não está definida).", + "HINT_TS_TYPE_GENERATION_WORKER_NO_CONFIG": "Este worker é iniciado pelo modo estrito do TypeScript com sua configuração na variável de ambiente SLOTHLET_CONFIG. Deixe o slothlet criá-lo em vez de executar o arquivo diretamente.", "TS_TYPE_GENERATION_PROCESS_EXITED": "O processo de geração de tipos saiu com o código {code}: {output}", "HINT_TS_TYPE_GENERATION_PROCESS_EXITED": "O processo filho de geração de tipos saiu com um código diferente de zero. Verifique a saída em busca de erros do compilador e verifique se as fontes do TypeScript são válidas.", "TS_TYPE_CHECK_ERRORS": "Erros de tipo TypeScript encontrados em '{filePath}':\n{errors}", diff --git a/src/lib/i18n/languages/ru-ru.json b/src/lib/i18n/languages/ru-ru.json index a7a6164cb..e61d7ecbb 100644 --- a/src/lib/i18n/languages/ru-ru.json +++ b/src/lib/i18n/languages/ru-ru.json @@ -353,7 +353,9 @@ "TS_TYPE_GENERATION_FAILED": "Ошибка генерации типов: {error}", "HINT_TS_TYPE_GENERATION_FAILED": "Дочерний процесс генерации типов завершился с ошибкой. Проверьте вывод.", "TS_TYPE_GENERATION_FORK_FAILED": "Не удалось форкнуть процесс генерации типов: {error}", - "HINT_TS_TYPE_GENERATION_FORK_FAILED": "Убедитесь, что Node.js может форкать процессы и существует tools/build/generate-types-worker.mjs.", + "HINT_TS_TYPE_GENERATION_FORK_FAILED": "Убедитесь, что Node.js может форкать процессы и существует lib/processors/type-generation-worker.mjs.", + "TS_TYPE_GENERATION_WORKER_NO_CONFIG": "Процесс генерации типов запущен без конфигурации (SLOTHLET_CONFIG не задана).", + "HINT_TS_TYPE_GENERATION_WORKER_NO_CONFIG": "Этот процесс запускается строгим режимом TypeScript с конфигурацией в переменной окружения SLOTHLET_CONFIG. Позвольте slothlet запустить его, а не выполняйте файл напрямую.", "TS_TYPE_GENERATION_PROCESS_EXITED": "Процесс генерации типов завершился с кодом {code}: {output}", "HINT_TS_TYPE_GENERATION_PROCESS_EXITED": "Проверьте вывод дочернего процесса на ошибки компилятора.", "TS_TYPE_CHECK_ERRORS": "Ошибки типов TypeScript в '{filePath}':\n{errors}", diff --git a/src/lib/i18n/languages/zh-cn.json b/src/lib/i18n/languages/zh-cn.json index b08e38797..48190e840 100644 --- a/src/lib/i18n/languages/zh-cn.json +++ b/src/lib/i18n/languages/zh-cn.json @@ -353,7 +353,9 @@ "TS_TYPE_GENERATION_FAILED": "类型生成失败:{error}", "HINT_TS_TYPE_GENERATION_FAILED": "生成 TypeScript 声明的子进程遇到错误。请检查错误信息并确保 TypeScript 源文件有效。", "TS_TYPE_GENERATION_FORK_FAILED": "无法派生类型生成进程:{error}", - "HINT_TS_TYPE_GENERATION_FORK_FAILED": "类型生成工作进程无法启动。请确保 Node.js 有权限派生子进程,并且存在 tools/build/generate-types-worker.mjs 脚本。", + "HINT_TS_TYPE_GENERATION_FORK_FAILED": "类型生成工作进程无法启动。请确保 Node.js 有权限派生子进程,并且存在 lib/processors/type-generation-worker.mjs 脚本。", + "TS_TYPE_GENERATION_WORKER_NO_CONFIG": "类型生成工作进程在没有配置的情况下启动(未设置 SLOTHLET_CONFIG)。", + "HINT_TS_TYPE_GENERATION_WORKER_NO_CONFIG": "此工作进程由 TypeScript strict 模式启动,其配置位于 SLOTHLET_CONFIG 环境变量中。请让 slothlet 派生它,而不是直接运行该文件。", "TS_TYPE_GENERATION_PROCESS_EXITED": "类型生成进程以代码 {code} 退出:{output}", "HINT_TS_TYPE_GENERATION_PROCESS_EXITED": "类型生成子进程以非零代码退出。检查输出中的编译器错误并验证 TypeScript 源是否有效。", "TS_TYPE_CHECK_ERRORS": "在 '{filePath}' 中发现 TypeScript 类型错误:\n{errors}", diff --git a/src/lib/processors/loader.mjs b/src/lib/processors/loader.mjs index 12acab40c..2e9fb26c3 100644 --- a/src/lib/processors/loader.mjs +++ b/src/lib/processors/loader.mjs @@ -131,6 +131,21 @@ function compileHidden(globs) { * @extends ComponentBase * @package */ +/** + * Absolute path of the worker strict TypeScript mode forks to generate the api's declaration file. It + * lives next to this module, so it ships wherever the loader does (`dist/lib/processors/` when + * installed) (#500). Resolved on call, from the Node-only strict-mode path: `path`/`url` are `null` + * in browser mode, and the literal `new URL("./…", import.meta.url)` form is avoided because bundlers + * and vite treat it as a module reference and load the worker into the current process. + * @returns {string} Absolute path of `type-generation-worker.mjs`. + * @internal + * @example + * fork(typeGenerationWorkerPath(), [], { stdio: ["pipe", "pipe", "pipe", "ipc"] }); + */ +export function typeGenerationWorkerPath() { + return path.join(path.dirname(url.fileURLToPath(import.meta.url)), "type-generation-worker.mjs"); +} + export class Loader extends ComponentBase { static slothletProperty = "loader"; @@ -186,12 +201,9 @@ export class Loader extends ComponentBase { // Generate types if not already generated for this instance if (!this.slothlet._typesGenerated) { const { fork } = await import("child_process"); - const path = await import("path"); - const { fileURLToPath } = await import("url"); - // Get the path to the type generation script (in tools/ not src/tools/) - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - const scriptPath = path.resolve(__dirname, "../../../tools/build/generate-types-worker.mjs"); + // The worker ships next to this file (src/lib/processors → dist/lib/processors). + const scriptPath = typeGenerationWorkerPath(); // Prepare config for child process // Note: Child process needs 'dir' not 'root', and should use eager mode diff --git a/src/lib/processors/type-generation-worker.mjs b/src/lib/processors/type-generation-worker.mjs new file mode 100644 index 000000000..21456d773 --- /dev/null +++ b/src/lib/processors/type-generation-worker.mjs @@ -0,0 +1,85 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /src/lib/processors/type-generation-worker.mjs + * @Date: 2026-02-14T18:14:33-08:00 (1771121673) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-03-01 20:22:18 -08:00 (1772425338) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Worker for strict TypeScript mode's type generation. + * @module @cldmv/slothlet/processors/type-generation-worker + * @internal + * + * @description + * Strict mode forks this file (see `typeGenerationWorkerPath()` in `loader.mjs`) to build an eager, + * fast-mode instance of the api and write its declaration file in a separate process, so the build + * never shares a module cache with the instance being loaded. The config arrives in the + * `SLOTHLET_CONFIG` environment variable, and the result goes back to the parent over IPC as + * `{ type: "success" }` or `{ type: "error", error }`. + * + * It ships in `dist/lib/processors/` and imports only `@cldmv/slothlet` self-references, which + * resolve to `src/` under the `slothlet-dev` condition and to `dist/` in an installed package (#500). + * Importing the module does nothing; only running it as the process's entry script starts a build. + */ + +import { fileURLToPath } from "node:url"; +import { resolve } from "node:path"; +import slothlet from "@cldmv/slothlet"; +import { generateTypes } from "@cldmv/slothlet/processors/type-generator"; +import { SlothletError } from "@cldmv/slothlet/errors"; + +/** + * Build the api described by `configJson` and write its declaration file. + * @param {string|undefined} configJson - The serialized worker config (`SLOTHLET_CONFIG`): the + * slothlet config for an eager, fast-mode instance plus `types` (`{ output, interfaceName, ... }`). + * @param {((message: {type: string, error?: string}) => void)|undefined} send - IPC sender to the + * parent (`process.send` in the fork); `undefined` when there is no IPC channel. + * @returns {Promise} The process exit code: `0` on success, `1` on failure. + * @example + * const code = await runTypeGeneration(process.env.SLOTHLET_CONFIG, process.send?.bind(process)); + */ +export async function runTypeGeneration(configJson, send) { + let api = null; + try { + if (!configJson) { + throw new SlothletError("TS_TYPE_GENERATION_WORKER_NO_CONFIG", {}, null, { validationError: true }); + } + const config = JSON.parse(configJson); + api = await slothlet(config); + await generateTypes(api, config.types); + send?.({ type: "success" }); + return 0; + } catch (error) { + send?.({ type: "error", error: error.message }); + return 1; + } finally { + await api?.slothlet.shutdown(); + } +} + +/** + * Run the type generation and hand its exit code to `exit`, but only when `argv1` is this file — + * i.e. when the module is the process's entry script (the strict-mode fork). Any other import is a + * no-op. + * @param {string|undefined} argv1 - The entry script path (`process.argv[1]`). + * @param {string|undefined} configJson - The serialized worker config (`SLOTHLET_CONFIG`). + * @param {((message: {type: string, error?: string}) => void)|undefined} send - IPC sender to the parent. + * @param {(code: number) => void} exit - Called with the exit code. + * @returns {Promise|null} The pending run, or `null` when this module is not the entry script. + * @example + * runIfEntry(process.argv[1], process.env.SLOTHLET_CONFIG, process.send?.bind(process), process.exit.bind(process)); + */ +export function runIfEntry(argv1, configJson, send, exit) { + if (!argv1 || resolve(argv1) !== fileURLToPath(import.meta.url)) { + return null; + } + return runTypeGeneration(configJson, send).then(exit); +} + +runIfEntry(process.argv[1], process.env.SLOTHLET_CONFIG, process.send?.bind(process), process.exit.bind(process)); diff --git a/tests/vitests/suites/typescript/typescript-strict-mode-packaged.test.vitest.mjs b/tests/vitests/suites/typescript/typescript-strict-mode-packaged.test.vitest.mjs new file mode 100644 index 000000000..f6c2a01f9 --- /dev/null +++ b/tests/vitests/suites/typescript/typescript-strict-mode-packaged.test.vitest.mjs @@ -0,0 +1,154 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/typescript/typescript-strict-mode-packaged.test.vitest.mjs + * @Date: 2026-09-28 09:44:57 -07:00 (1790613897) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 09:53:16 -07:00 (1790614396) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Regression (#500): strict TypeScript mode forks a type-generation worker, and that worker + * must ship in the published package. It lived in `tools/build/` and imported `../../src/...`; neither + * `tools/` nor `src/` is in `package.json` `files`, so strict mode could only work inside this repo. + * + * The worker must sit inside the source tree the build copies into `dist/` (`src/lib/...` → + * `dist/lib/...`), and import only Node builtins or `@cldmv/slothlet` self-references, which resolve + * to `src/` under the `slothlet-dev` condition and to `dist/` in an installed package. + * + * @module tests/vitests/suites/typescript/typescript-strict-mode-packaged + */ + +import { describe, it, expect, afterAll } from "vitest"; +import { readFileSync, existsSync } from "node:fs"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { builtinModules } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { typeGenerationWorkerPath } from "@cldmv/slothlet/processors/loader"; +import { runTypeGeneration, runIfEntry } from "@cldmv/slothlet/processors/type-generation-worker"; +import { makeTestTmpDir } from "../../setup/test-fixtures-tmp.mjs"; + +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../.."); +const LIB_ROOT = path.join(REPO_ROOT, "src", "lib"); + +describe("strict mode type-generation worker ships with the package (#500)", () => { + it("forks a worker inside the lib tree the build copies into dist/", () => { + const workerPath = typeGenerationWorkerPath(); + expect(existsSync(workerPath)).toBe(true); + const relative = path.relative(LIB_ROOT, workerPath); + expect(relative.startsWith("..") || path.isAbsolute(relative)).toBe(false); + }); + + it("imports only Node builtins and @cldmv/slothlet self-references", () => { + const source = readFileSync(typeGenerationWorkerPath(), "utf8"); + const specifiers = [...source.matchAll(/^\s*import\s+(?:[^"']*?\s+from\s+)?["']([^"']+)["']/gm)].map((m) => m[1]); + expect(specifiers.length).toBeGreaterThan(0); + const builtins = new Set([...builtinModules, ...builtinModules.map((name) => `node:${name}`)]); + const unpublished = specifiers.filter((spec) => !builtins.has(spec) && !spec.startsWith("@cldmv/slothlet")); + expect(unpublished).toEqual([]); + }); + + it("does nothing when imported rather than run as the process's entry script", async () => { + // Importing the worker (a bundler following the reference, a test, a tool scanning the package) + // must not start a build or call process.exit on the importer. + const before = process.exitCode; + await import(typeGenerationWorkerPath()); + expect(process.exitCode).toBe(before); + }); + + it("is inside the published file list", () => { + const pkg = JSON.parse(readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")); + expect(pkg.files).toContain("dist/"); + }); +}); + +describe("type-generation worker, driven in-process (#500)", () => { + const roots = []; + + afterAll(async () => { + await Promise.allSettled(roots.map((root) => rm(root, { recursive: true, force: true }))); + }); + + /** + * A tmp api folder with one leaf, plus the worker config that builds it. + * @returns {Promise<{configJson: string, output: string}>} + */ + async function workerFixture() { + const root = await makeTestTmpDir("type-generation-worker"); + roots.push(root); + const apiDir = path.join(root, "api"); + await mkdir(apiDir, { recursive: true }); + await writeFile( + path.join(apiDir, "math.mjs"), + "/** @param {number} a @param {number} b @returns {number} */\nexport function add(a, b) { return a + b; }\n", + "utf8" + ); + const output = path.join(root, "types", "api.d.ts"); + const configJson = JSON.stringify({ + base: apiDir, + mode: "eager", + silent: true, + typescript: { enabled: true, mode: "fast" }, + types: { output, interfaceName: "WorkerApi" } + }); + return { configJson, output }; + } + + it("builds the api, writes the declaration, and reports success", async () => { + const { configJson, output } = await workerFixture(); + const messages = []; + const code = await runTypeGeneration(configJson, (message) => messages.push(message)); + expect(code).toBe(0); + expect(messages).toEqual([{ type: "success" }]); + expect(readFileSync(output, "utf8")).toContain("interface WorkerApi"); + }); + + it("reports an error and exit code 1 when SLOTHLET_CONFIG is missing", async () => { + const messages = []; + const code = await runTypeGeneration(undefined, (message) => messages.push(message)); + expect(code).toBe(1); + expect(messages).toHaveLength(1); + expect(messages[0].type).toBe("error"); + expect(messages[0].error).toMatch(/SLOTHLET_CONFIG/); + }); + + it("reports an error and exit code 1 when type generation fails", async () => { + const { configJson } = await workerFixture(); + const broken = JSON.parse(configJson); + delete broken.types.output; + const messages = []; + const code = await runTypeGeneration(JSON.stringify(broken), (message) => messages.push(message)); + expect(code).toBe(1); + expect(messages[0].type).toBe("error"); + }); + + it("works without an IPC channel", async () => { + const { configJson } = await workerFixture(); + await expect(runTypeGeneration(configJson, undefined)).resolves.toBe(0); + await expect(runTypeGeneration(undefined, undefined)).resolves.toBe(1); + }); + + it("runs only when the worker is the process's entry script", async () => { + const exits = []; + expect(runIfEntry(undefined, "{}", undefined, (code) => exits.push(code))).toBeNull(); + expect(runIfEntry(path.join(REPO_ROOT, "index.mjs"), "{}", undefined, (code) => exits.push(code))).toBeNull(); + expect(exits).toEqual([]); + + const { configJson, output } = await workerFixture(); + const messages = []; + await runIfEntry( + typeGenerationWorkerPath(), + configJson, + (message) => messages.push(message), + (code) => exits.push(code) + ); + expect(exits).toEqual([0]); + expect(messages).toEqual([{ type: "success" }]); + expect(readFileSync(output, "utf8")).toContain("interface WorkerApi"); + }); +}); diff --git a/tools/build/generate-types-worker.mjs b/tools/build/generate-types-worker.mjs deleted file mode 100644 index 4b2955e9d..000000000 --- a/tools/build/generate-types-worker.mjs +++ /dev/null @@ -1,69 +0,0 @@ -#!/usr/bin/env node -/** - * @Project: @cldmv/slothlet - * @Filename: /tools/build/generate-types-worker.mjs - * @Date: 2026-02-14T18:14:33-08:00 (1771121673) - * @Author: Nate Corcoran - * @Email: - * ----- - * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) - * @Last modified time: 2026-03-01 20:22:18 -08:00 (1772425338) - * ----- - * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. - */ - -/** - * Worker script for generating TypeScript declaration files - * This runs in a forked child process to avoid module cache conflicts - */ - -import slothlet from "../../index.mjs"; -import { generateTypes } from "../../src/lib/processors/type-generator.mjs"; - -async function main() { - try { - // Get config from environment - const configJson = process.env.SLOTHLET_CONFIG; - if (!configJson) { - throw new Error("SLOTHLET_CONFIG environment variable not set"); - } - - const config = JSON.parse(configJson); - - // Load API in eager mode with fast TypeScript mode - const api = await slothlet(config); - - try { - // Generate types from loaded API - await generateTypes(api, config.types); - - // Send success message to parent - if (process.send) { - process.send({ type: "success" }); - } - - // Shutdown cleanly - await api.slothlet.shutdown(); - - process.exit(0); - } catch (error) { - // Send error message to parent - if (process.send) { - process.send({ type: "error", error: error.message }); - } - - await api.slothlet.shutdown(); - process.exit(1); - } - } catch (error) { - console.error("Type generation worker failed:", error); - - if (process.send) { - process.send({ type: "error", error: error.message }); - } - - process.exit(1); - } -} - -main(); diff --git a/tools/ci/i18n-latin-tokens-accepted.json b/tools/ci/i18n-latin-tokens-accepted.json index a4e1f8dc3..464d84371 100644 --- a/tools/ci/i18n-latin-tokens-accepted.json +++ b/tools/ci/i18n-latin-tokens-accepted.json @@ -185,7 +185,19 @@ "Hook", "data" ], - "external_tooling_paths": ["npm", "install", "tools", "cldmv", "worker", "ALS"], + "external_tooling_paths": [ + "npm", + "install", + "tools", + "cldmv", + "worker", + "ALS", + "lib", + "processors", + "generation", + "SLOTHLET", + "CONFIG" + ], "fixture_or_example_names": ["math", "auth", "synthetic", "syntheticName"], "misc_remaining_debug_abbreviations": [ "IN", @@ -440,6 +452,11 @@ "cldmv", "worker", "ALS", + "lib", + "processors", + "generation", + "SLOTHLET", + "CONFIG", "math", "auth", "synthetic", diff --git a/types/src/lib/processors/loader.d.mts b/types/src/lib/processors/loader.d.mts index 86e5710d0..660415ef5 100644 --- a/types/src/lib/processors/loader.d.mts +++ b/types/src/lib/processors/loader.d.mts @@ -28,6 +28,18 @@ export function warnIfCoverageWithoutImporter(config: object, { worker, external * @extends ComponentBase * @package */ +/** + * Absolute path of the worker strict TypeScript mode forks to generate the api's declaration file. It + * lives next to this module, so it ships wherever the loader does (`dist/lib/processors/` when + * installed) (#500). Resolved on call, from the Node-only strict-mode path: `path`/`url` are `null` + * in browser mode, and the literal `new URL("./…", import.meta.url)` form is avoided because bundlers + * and vite treat it as a module reference and load the worker into the current process. + * @returns {string} Absolute path of `type-generation-worker.mjs`. + * @internal + * @example + * fork(typeGenerationWorkerPath(), [], { stdio: ["pipe", "pipe", "pipe", "ipc"] }); + */ +export function typeGenerationWorkerPath(): string; export class Loader extends ComponentBase { static slothletProperty: string; /** diff --git a/types/src/lib/processors/loader.d.mts.map b/types/src/lib/processors/loader.d.mts.map index d17743c75..7c73d7f1a 100644 --- a/types/src/lib/processors/loader.d.mts.map +++ b/types/src/lib/processors/loader.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"loader.d.mts","sourceRoot":"","sources":["../../../../src/lib/processors/loader.mjs"],"names":[],"mappings":"AA4DA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,sDAjBW,MAAM,6BAEd;IAAqC,MAAM,GAAnC,MAAM,GAAC,SAAS;IACI,YAAY;CAExC,GAAU,OAAO,CAmBnB;AAwCD;;;;;GAKG;AACH;IACC,gCAAmC;IAEnC;;;;OAIG;IACH,sBAHW,MAAM,EAKhB;IAED;;;;;;;;OAQG;IACH,4BAPW,MAAM,eACN,MAAM,aACN,MAAM,cACN,MAAM,GAAC,IAAI,GACT,OAAO,CAAC,MAAM,CAAC,CA0M3B;IAyED;;;;;;;;;;;;;;OAcG;IACH,0BAbW,MAAM,YAEd;QAA0B,UAAU;QACX,YAAY;QACZ,QAAQ;QACD,UAAU;QACM,MAAM;QAE5B,iBAAiB;QAClB,OAAO;KAChC,GAAU,OAAO,CAAC,MAAM,CAAC,CAqJ3B;IA8PD;;;;;OAKG;IACH,8BAJW,MAAM,GACJ,MAAM,CA+ClB;;CACD;8BAp2B6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"loader.d.mts","sourceRoot":"","sources":["../../../../src/lib/processors/loader.mjs"],"names":[],"mappings":"AA4DA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,sDAjBW,MAAM,6BAEd;IAAqC,MAAM,GAAnC,MAAM,GAAC,SAAS;IACI,YAAY;CAExC,GAAU,OAAO,CAmBnB;AAwCD;;;;;GAKG;AACH;;;;;;;;;;GAUG;AACH,4CALa,MAAM,CAOlB;AAED;IACC,gCAAmC;IAEnC;;;;OAIG;IACH,sBAHW,MAAM,EAKhB;IAED;;;;;;;;OAQG;IACH,4BAPW,MAAM,eACN,MAAM,aACN,MAAM,cACN,MAAM,GAAC,IAAI,GACT,OAAO,CAAC,MAAM,CAAC,CAuM3B;IAyED;;;;;;;;;;;;;;OAcG;IACH,0BAbW,MAAM,YAEd;QAA0B,UAAU;QACX,YAAY;QACZ,QAAQ;QACD,UAAU;QACM,MAAM;QAE5B,iBAAiB;QAClB,OAAO;KAChC,GAAU,OAAO,CAAC,MAAM,CAAC,CAqJ3B;IA8PD;;;;;OAKG;IACH,8BAJW,MAAM,GACJ,MAAM,CA+ClB;;CACD;8BAh3B6B,2BAA2B"} \ No newline at end of file diff --git a/types/src/lib/processors/type-generation-worker.d.mts b/types/src/lib/processors/type-generation-worker.d.mts new file mode 100644 index 000000000..86e32337e --- /dev/null +++ b/types/src/lib/processors/type-generation-worker.d.mts @@ -0,0 +1,31 @@ +/** + * Build the api described by `configJson` and write its declaration file. + * @param {string|undefined} configJson - The serialized worker config (`SLOTHLET_CONFIG`): the + * slothlet config for an eager, fast-mode instance plus `types` (`{ output, interfaceName, ... }`). + * @param {((message: {type: string, error?: string}) => void)|undefined} send - IPC sender to the + * parent (`process.send` in the fork); `undefined` when there is no IPC channel. + * @returns {Promise} The process exit code: `0` on success, `1` on failure. + * @example + * const code = await runTypeGeneration(process.env.SLOTHLET_CONFIG, process.send?.bind(process)); + */ +export function runTypeGeneration(configJson: string | undefined, send: ((message: { + type: string; + error?: string; +}) => void) | undefined): Promise; +/** + * Run the type generation and hand its exit code to `exit`, but only when `argv1` is this file — + * i.e. when the module is the process's entry script (the strict-mode fork). Any other import is a + * no-op. + * @param {string|undefined} argv1 - The entry script path (`process.argv[1]`). + * @param {string|undefined} configJson - The serialized worker config (`SLOTHLET_CONFIG`). + * @param {((message: {type: string, error?: string}) => void)|undefined} send - IPC sender to the parent. + * @param {(code: number) => void} exit - Called with the exit code. + * @returns {Promise|null} The pending run, or `null` when this module is not the entry script. + * @example + * runIfEntry(process.argv[1], process.env.SLOTHLET_CONFIG, process.send?.bind(process), process.exit.bind(process)); + */ +export function runIfEntry(argv1: string | undefined, configJson: string | undefined, send: ((message: { + type: string; + error?: string; +}) => void) | undefined, exit: (code: number) => void): Promise | null; +//# sourceMappingURL=type-generation-worker.d.mts.map \ No newline at end of file diff --git a/types/src/lib/processors/type-generation-worker.d.mts.map b/types/src/lib/processors/type-generation-worker.d.mts.map new file mode 100644 index 000000000..74e95e5dc --- /dev/null +++ b/types/src/lib/processors/type-generation-worker.d.mts.map @@ -0,0 +1 @@ +{"version":3,"file":"type-generation-worker.d.mts","sourceRoot":"","sources":["../../../../src/lib/processors/type-generation-worker.mjs"],"names":[],"mappings":"AAoCA;;;;;;;;;GASG;AACH,8CARW,MAAM,GAAC,SAAS,QAEhB,CAAC,CAAC,OAAO,EAAE;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAC,KAAK,IAAI,CAAC,GAAC,SAAS,GAE3D,OAAO,CAAC,MAAM,CAAC,CAqB3B;AAED;;;;;;;;;;;GAWG;AACH,kCARW,MAAM,GAAC,SAAS,cAChB,MAAM,GAAC,SAAS,QAChB,CAAC,CAAC,OAAO,EAAE;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAC,KAAK,IAAI,CAAC,GAAC,SAAS,QAC7D,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GACpB,OAAO,CAAC,IAAI,CAAC,GAAC,IAAI,CAS9B"} \ No newline at end of file From a1bd77c31cfbbfc43e8732613b641b529913bf49 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 28 Sep 2026 11:11:14 -0700 Subject: [PATCH 09/49] fix(context): route patched once through the emitter's own on/removeListener With the EventEmitter context patch enabled, stream.once("data") never fired: the patched once/prependOnceListener attached through the saved base EventEmitter.prototype.on/prependListener and detached through the saved base removeListener, bypassing subclass overrides. Readable relies on its on() override to resume() a paused stream for "data" (and to arm readableListening for "readable"), so a server-side socket's once("data") and events.once(socket, "data") hung while on("data") worked. Attach and detach through this.on / this.prependListener / this.removeListener, as native once does. The once-wrapper already carries the _slothletOriginal marker, so the patched on/removeListener pass it through without re-wrapping, keeping the v3.8.0 once + removeListener double-wrap fix intact. Fixes #503 --- src/lib/helpers/eventemitter-context.mjs | 62 ++--- ...mitter-once-stream-flowing.test.vitest.mjs | 257 ++++++++++++++++++ .../helpers/eventemitter-context.d.mts.map | 2 +- 3 files changed, 287 insertions(+), 34 deletions(-) create mode 100644 tests/vitests/suites/context/eventemitter-once-stream-flowing.test.vitest.mjs diff --git a/src/lib/helpers/eventemitter-context.mjs b/src/lib/helpers/eventemitter-context.mjs index 3f4e4fa17..c3a06b9e3 100644 --- a/src/lib/helpers/eventemitter-context.mjs +++ b/src/lib/helpers/eventemitter-context.mjs @@ -397,11 +397,21 @@ function runtime_patchOn() { * timer fires ~5s after handshake → `socket.destroy` → reconnect loop * every 5–8s. * - * Fix: implement once semantics directly — attach via the saved-original - * `on` (skips the patched-on double-wrap), auto-cleanup via the saved- - * original `removeListener` (skips the patched-removeListener re-entry), - * and set `.listener = userFn` on the wrapper to mirror Node's contract - * for libraries that introspect `rawListeners()[i].listener`. + * Fix: implement once semantics directly and set `.listener = userFn` on + * the wrapper to mirror Node's contract for libraries that introspect + * `rawListeners()[i].listener`. + * + * Attach and detach exactly as native `once` does — through `this.on` and + * `this.removeListener`, NOT the saved base-prototype methods (#503). A + * subclass may override `on`/`removeListener` with side effects the event + * depends on: `Readable.prototype.on("data")` calls `resume()` to start the + * flow, and `Readable.prototype.on("readable")` arms `readableListening`. + * Calling the base `EventEmitter.prototype.on` directly attached the + * listener but skipped `resume()`, so `socket.once("data")` never fired. + * Dispatching through `this.on` is safe from the double-wrap above because + * `runtime_onceWrapper` carries `_slothletOriginal`: the patched `on` and + * `removeListener` see the marker and pass it straight to the original + * without wrapping or tracking it again. * * @private */ @@ -409,16 +419,6 @@ function runtime_patchOnce() { const original = EventEmitter.prototype.once; originalMethods.set("once", original); - // Capture the saved-original `on` and `removeListener` at patch time. - // `on` is patched before `once` (see enableEventEmitterPatching ordering) - // so originalMethods.get("on") is always populated; the ?? fallback only - // fires if the patch order changes in the future. `removeListener` is - // patched AFTER `once`, so the fallback to the current prototype value - // (still the unpatched native at this point) is the normal path there. - /* v8 ignore next */ - const originalOn = originalMethods.get("on") ?? EventEmitter.prototype.on; - const originalRemove = originalMethods.get("removeListener") ?? EventEmitter.prototype.removeListener; - EventEmitter.prototype.once = function (event, listener) { // Track this emitter if created in slothlet context runtime_maybeTrackEmitter(this); @@ -430,11 +430,12 @@ function runtime_patchOnce() { const wrapped = runtime_wrapEventListener(listener); const self = this; - // Once-wrapper: detach via saved-original removeListener (no re-entry - // into the patched path), then drop our tracking entry, then call the - // user's wrapped fn. Identity-based untrack matches mixed on+once. + // Once-wrapper: detach through the emitter's own removeListener (as + // native once does, so subclass overrides run), then drop our tracking + // entry, then call the user's wrapped fn. Identity-based untrack + // matches mixed on+once. const runtime_onceWrapper = function (...args) { - originalRemove.call(self, event, runtime_onceWrapper); + self.removeListener(event, runtime_onceWrapper); runtime_untrackSpecificWrapper(self, event, listener, runtime_onceWrapper); return wrapped.apply(this, args); }; @@ -445,8 +446,9 @@ function runtime_patchOnce() { runtime_onceWrapper.listener = listener; runtime_trackListener(this, event, listener, runtime_onceWrapper); - // Attach via saved-original `on` to avoid the patched-on double-wrap. - return originalOn.call(this, event, runtime_onceWrapper); + // Attach through the emitter's own `on` so subclass overrides run (#503); + // the `_slothletOriginal` marker stops the patched `on` from re-wrapping. + return this.on(event, runtime_onceWrapper); }; } @@ -478,8 +480,9 @@ function runtime_patchPrependListener() { * Same delegation pitfall as `runtime_patchOnce` — native * `prependOnceListener` internally calls `this.prependListener(...)` which * routes through the patched prototype and double-wraps. Same fix shape: - * attach via saved-original `prependListener`, auto-cleanup via saved- - * original `removeListener`, set `.listener = userFn`. + * attach through `this.prependListener`, auto-cleanup through + * `this.removeListener` (both see the `_slothletOriginal` marker and pass the + * wrapper through unwrapped), set `.listener = userFn`. * * @private */ @@ -487,13 +490,6 @@ function runtime_patchPrependOnceListener() { const original = EventEmitter.prototype.prependOnceListener; originalMethods.set("prependOnceListener", original); - // `prependListener` is patched before `prependOnceListener` in - // enableEventEmitterPatching, so the ?? fallback is unreachable in the - // normal patch order; it only fires if the patch order changes. - /* v8 ignore next */ - const originalPrepend = originalMethods.get("prependListener") ?? EventEmitter.prototype.prependListener; - const originalRemove = originalMethods.get("removeListener") ?? EventEmitter.prototype.removeListener; - EventEmitter.prototype.prependOnceListener = function (event, listener) { // Track this emitter if created in slothlet context runtime_maybeTrackEmitter(this); @@ -506,7 +502,7 @@ function runtime_patchPrependOnceListener() { const self = this; const runtime_onceWrapper = function (...args) { - originalRemove.call(self, event, runtime_onceWrapper); + self.removeListener(event, runtime_onceWrapper); runtime_untrackSpecificWrapper(self, event, listener, runtime_onceWrapper); return wrapped.apply(this, args); }; @@ -516,8 +512,8 @@ function runtime_patchPrependOnceListener() { runtime_onceWrapper.listener = listener; runtime_trackListener(this, event, listener, runtime_onceWrapper); - // Attach via saved-original `prependListener` to avoid double-wrap. - return originalPrepend.call(this, event, runtime_onceWrapper); + // Attach through the emitter's own `prependListener`, as native does (#503). + return this.prependListener(event, runtime_onceWrapper); }; } diff --git a/tests/vitests/suites/context/eventemitter-once-stream-flowing.test.vitest.mjs b/tests/vitests/suites/context/eventemitter-once-stream-flowing.test.vitest.mjs new file mode 100644 index 000000000..eb16783f3 --- /dev/null +++ b/tests/vitests/suites/context/eventemitter-once-stream-flowing.test.vitest.mjs @@ -0,0 +1,257 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/context/eventemitter-once-stream-flowing.test.vitest.mjs + * @Date: 2026-09-28T10:14:31-07:00 (1790615671) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 10:14:31 -07:00 (1790615671) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview `once` must dispatch through the emitter's own `on` / `removeListener` (#503). + * + * @description + * Native `EventEmitter.prototype.once` attaches with `this.on(...)` and detaches with + * `this.removeListener(...)`, so a subclass override of either runs. `Readable` relies on that: + * `Readable.prototype.on("data")` is what calls `resume()` and switches a paused stream into flowing + * mode, and `Readable.prototype.on("readable")` is what arms `readableListening`. + * + * The patched `once` / `prependOnceListener` in `src/lib/helpers/eventemitter-context.mjs` used to + * attach through the saved base `EventEmitter.prototype.on` / `prependListener` and detach through + * the saved base `removeListener`, skipping every subclass override. The listener was attached, but + * a paused stream never started flowing — `socket.once("data")` and `events.once(socket, "data")` + * never fired while `socket.on("data")` did. + * + * These tests pin the native dispatch, and re-check that the fix did not undo the v3.8.0 + * once/removeListener double-wrap fix (the once-wrapper is not wrapped a second time by the + * patched `on`) or the context capture that is the reason the patch exists. + * + * @module tests/vitests/suites/context/eventemitter-once-stream-flowing.test.vitest + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { EventEmitter, once as eventsOnce } from "node:events"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { Readable } from "node:stream"; +import net from "node:net"; +import slothlet from "@cldmv/slothlet"; +import { TEST_DIRS } from "../../setup/vitest-helper.mjs"; + +/** + * Race a promise against a timeout so a listener that never fires fails the test instead of hanging it. + * @param {Promise<*>} promise - Promise expected to settle. + * @param {number} [ms=2000] - Timeout in milliseconds. + * @returns {Promise<*>} The promise's value, or the string "timeout". + */ +function withTimeout(promise, ms = 2000) { + let timer; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve("timeout"), ms); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +/** + * Open a loopback TCP server, connect a client that writes `payload`, and hand the server-side + * socket to `onConnection`. Resolves with whatever `onConnection` resolves with. + * @param {(conn: net.Socket) => Promise<*>} onConnection - Registers the listener under test. + * @param {string} [payload="hello"] - Bytes the client writes. + * @returns {Promise<*>} The value `onConnection` resolves with, or "timeout". + */ +async function serverSide(onConnection, payload = "hello") { + const server = net.createServer(); + const sockets = []; + try { + const result = new Promise((resolve) => { + server.on("connection", (conn) => { + sockets.push(conn); + resolve(onConnection(conn)); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const client = net.connect(server.address().port, "127.0.0.1", () => client.write(payload)); + client.on("error", () => {}); + sockets.push(client); + return await withTimeout(result); + } finally { + for (const s of sockets) s.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + } +} + +describe("EventEmitter — once dispatches through the emitter's own on/removeListener (#503)", () => { + let api; + beforeAll(async () => { + api = await slothlet({ + base: TEST_DIRS.API_TEST, + mode: "eager", + runtime: "async", + silent: true + }); + }); + afterAll(async () => { + await api?.shutdown?.(); + }); + + // ─── Streams: once("data") must start the flow ────────────────────────── + + it("Readable.once('data') resumes a paused stream and fires", async () => { + const stream = new Readable({ read() {} }); + stream.push("chunk"); + + const got = await withTimeout(new Promise((resolve) => stream.once("data", (d) => resolve(String(d))))); + + expect(got).toBe("chunk"); + expect(stream.readableFlowing).toBe(true); + expect(stream.listenerCount("data")).toBe(0); + stream.destroy(); + }); + + it("Readable.once('readable') fires and detaches", async () => { + const stream = new Readable({ read() {} }); + stream.push("chunk"); + + const got = await withTimeout(new Promise((resolve) => stream.once("readable", () => resolve(String(stream.read()))))); + + expect(got).toBe("chunk"); + expect(stream.listenerCount("readable")).toBe(0); + stream.destroy(); + }); + + it("server-side net.Socket once('data') fires with the client's bytes", async () => { + const got = await serverSide((conn) => new Promise((resolve) => conn.once("data", (d) => resolve(String(d))))); + expect(got).toBe("hello"); + }); + + it("events.once(socket, 'data') resolves", async () => { + const got = await serverSide(async (conn) => { + const [d] = await eventsOnce(conn, "data"); + return String(d); + }); + expect(got).toBe("hello"); + }); + + it("on('data') on a server-side socket is unchanged", async () => { + const got = await serverSide((conn) => new Promise((resolve) => conn.on("data", (d) => resolve(String(d))))); + expect(got).toBe("hello"); + }); + + // ─── Subclass overrides are honoured, exactly like native once ────────── + + it("once / prependOnceListener call the subclass's on / prependListener / removeListener", () => { + const calls = []; + class Recording extends EventEmitter { + on(event, fn) { + calls.push(`on:${String(event)}`); + return super.on(event, fn); + } + prependListener(event, fn) { + calls.push(`prependListener:${String(event)}`); + return super.prependListener(event, fn); + } + removeListener(event, fn) { + calls.push(`removeListener:${String(event)}`); + return super.removeListener(event, fn); + } + } + + const emitter = new Recording(); + let a = 0; + let b = 0; + emitter.once("a", () => a++); + emitter.prependOnceListener("b", () => b++); + emitter.emit("a"); + emitter.emit("a"); + emitter.emit("b"); + emitter.emit("b"); + + expect(a).toBe(1); + expect(b).toBe(1); + expect(calls).toEqual(["on:a", "prependListener:b", "removeListener:a", "removeListener:b"]); + expect(emitter.listenerCount("a")).toBe(0); + expect(emitter.listenerCount("b")).toBe(0); + }); + + // ─── Plain EventEmitter: once semantics and no double-wrap ────────────── + + it("plain EventEmitter once fires exactly once and leaves a single, introspectable wrapper", () => { + const emitter = new EventEmitter(); + let calls = 0; + const fn = () => calls++; + + emitter.once("e", fn); + const raw = emitter.rawListeners("e"); + // Routing the attach through the patched `on` must not wrap the once-wrapper again. + expect(raw).toHaveLength(1); + expect(raw[0].listener).toBe(fn); + + emitter.emit("e"); + emitter.emit("e"); + expect(calls).toBe(1); + expect(emitter.listenerCount("e")).toBe(0); + }); + + it("removeListener of a once-listener before it fires detaches it", () => { + const emitter = new EventEmitter(); + let calls = 0; + const fn = () => calls++; + + emitter.once("e", fn); + emitter.prependOnceListener("e", fn); + expect(emitter.listenerCount("e")).toBe(2); + + emitter.removeListener("e", fn); + emitter.removeListener("e", fn); + emitter.emit("e"); + + expect(calls).toBe(0); + expect(emitter.listenerCount("e")).toBe(0); + }); + + it("once and on for the same function coexist: once detaches only itself", () => { + const emitter = new EventEmitter(); + let calls = 0; + const fn = () => calls++; + + emitter.on("e", fn); + emitter.once("e", fn); + emitter.emit("e"); + expect(calls).toBe(2); + expect(emitter.listenerCount("e")).toBe(1); + + emitter.emit("e"); + expect(calls).toBe(3); + + emitter.removeListener("e", fn); + expect(emitter.listenerCount("e")).toBe(0); + }); + + it("events.once on a plain EventEmitter resolves with the emitted args", async () => { + const emitter = new EventEmitter(); + setImmediate(() => emitter.emit("ready", 1, 2)); + const args = await withTimeout(eventsOnce(emitter, "ready")); + expect(args).toEqual([1, 2]); + expect(emitter.listenerCount("ready")).toBe(0); + }); + + // ─── The patch's purpose survives: once listeners still capture context ── + + it("a once listener still runs in the async context it was registered in", () => { + const als = new AsyncLocalStorage(); + const emitter = new EventEmitter(); + let seen = "unset"; + + als.run("registered", () => { + emitter.once("e", () => { + seen = als.getStore(); + }); + }); + als.run("emitted", () => emitter.emit("e")); + + expect(seen).toBe("registered"); + }); +}); diff --git a/types/src/lib/helpers/eventemitter-context.d.mts.map b/types/src/lib/helpers/eventemitter-context.d.mts.map index d2c6cae43..0d24139ae 100644 --- a/types/src/lib/helpers/eventemitter-context.d.mts.map +++ b/types/src/lib/helpers/eventemitter-context.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"eventemitter-context.d.mts","sourceRoot":"","sources":["../../../../src/lib/helpers/eventemitter-context.mjs"],"names":[],"mappings":"AAyCA;;;;;GAKG;AACH,8DAEC;AAyiBD;;;;;;GAMG;AACH,mDAiBC;AAED;;;;;GAKG;AACH,oDAsBC;AAED;;;;;;GAMG;AACH,qDAyBC"} \ No newline at end of file +{"version":3,"file":"eventemitter-context.d.mts","sourceRoot":"","sources":["../../../../src/lib/helpers/eventemitter-context.mjs"],"names":[],"mappings":"AAyCA;;;;;GAKG;AACH,8DAEC;AAqiBD;;;;;;GAMG;AACH,mDAiBC;AAED;;;;;GAKG;AACH,oDAsBC;AAED;;;;;;GAMG;AACH,qDAyBC"} \ No newline at end of file From fbd6862cd8754cb55d8b95ed0ffe22952c37f38d Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 28 Sep 2026 11:26:15 -0700 Subject: [PATCH 10/49] docs(typescript): use jsdoc-parseable callback types in the type-generation worker jsdoc's type parser rejects TypeScript arrow-function types, so docs:build failed on the worker's `send`/`exit` params. They now reference @callback typedefs (WorkerMessageSender, WorkerExit); docs/generated/API.md is regenerated. --- docs/generated/API.md | 4 +- src/lib/processors/type-generation-worker.mjs | 22 +++++++++-- .../processors/type-generation-worker.d.mts | 39 +++++++++++++------ .../type-generation-worker.d.mts.map | 2 +- 4 files changed, 49 insertions(+), 18 deletions(-) diff --git a/docs/generated/API.md b/docs/generated/API.md index 06a802cae..a1ee10e6e 100644 --- a/docs/generated/API.md +++ b/docs/generated/API.md @@ -3267,7 +3267,7 @@ api.slothlet.permissions.control.readGating(true); // resume #### api.slothlet.permissions.control.seal() ⇒ void -Seal the permission control surface (one-way, no unseal). After sealing, enable/disable, addRule/removeRule, and readGating throw PERMISSION_SEALED. Enforcement continues to evaluate and shutdown() still works. Idempotent. +Seal the permission control surface (one-way, no unseal). After sealing, enable/disable, addRule/removeRule, readGating, and principal.register/principal.unregister throw PERMISSION_SEALED. Enforcement continues to evaluate, principal.invalidate and shutdown() still work. Idempotent. **Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) @@ -3713,7 +3713,7 @@ await api.slothlet.shutdown(); | slothlet.metadata | object | Module metadata accessor. | | slothlet.owner | object | Direct path ownership accessor (shorthand for `slothlet.ownership`). | | slothlet.ownership | object | Module ownership registry. | -| slothlet.permissions | object | Permission system surface — present whenever a `permissions` block is configured. Rule registration (`addRule`/`removeRule`), self/global introspection (`self.*`, `global.*`), and runtime control (`control.*`) live here. Only the `control` sub-namespace is typed in this typedef; the rule-management methods are documented in `docs/PERMISSIONS.md`. | +| slothlet.permissions | object | Permission system surface — present whenever a `permissions` block is configured. Rule registration (`addRule`/`removeRule`), principal resolvers (`principal.*`), self/global introspection (`self.*`, `global.*`), and runtime control (`control.*`) live here. Only the `control` sub-namespace is typed in this typedef; the rule-management methods are documented in `docs/PERMISSIONS.md`. | | slothlet.permissions.control | object | Runtime control over permission enforcement. | | slothlet.permissions.control.enabled | boolean | Current permission-enforcement state. Exposed as a getter so descriptor-based reads remain permission-gated. %%sig: boolean%% %%example: // ESM usage via slothlet API\|import slothlet from "@cldmv/slothlet";\|const api = await slothlet({ base: './api', permissions: { defaultPolicy: 'allow' } });\|const on = api.slothlet.permissions.control.enabled;%% | | slothlet.permissions.control.readGatingEnabled | boolean | Current read-level gating state. `true` when terminal data-value property reads are permission-gated. Exposed as a getter so descriptor-based reads remain permission-gated. %%sig: boolean%% %%example: // ESM usage via slothlet API\|import slothlet from "@cldmv/slothlet";\|const api = await slothlet({ base: './api', permissions: { defaultPolicy: 'allow' } });\|const gated = api.slothlet.permissions.control.readGatingEnabled;%% | diff --git a/src/lib/processors/type-generation-worker.mjs b/src/lib/processors/type-generation-worker.mjs index 21456d773..84ed65486 100644 --- a/src/lib/processors/type-generation-worker.mjs +++ b/src/lib/processors/type-generation-worker.mjs @@ -34,12 +34,26 @@ import slothlet from "@cldmv/slothlet"; import { generateTypes } from "@cldmv/slothlet/processors/type-generator"; import { SlothletError } from "@cldmv/slothlet/errors"; +/** + * IPC sender to the parent process (`process.send` in the fork). + * @callback WorkerMessageSender + * @param {{type: string, error: (string|undefined)}} message - `{ type: "success" }` or `{ type: "error", error }`. + * @returns {void} + */ + +/** + * Receives the worker's exit code (`process.exit` in the fork). + * @callback WorkerExit + * @param {number} code - `0` on success, `1` on failure. + * @returns {void} + */ + /** * Build the api described by `configJson` and write its declaration file. * @param {string|undefined} configJson - The serialized worker config (`SLOTHLET_CONFIG`): the * slothlet config for an eager, fast-mode instance plus `types` (`{ output, interfaceName, ... }`). - * @param {((message: {type: string, error?: string}) => void)|undefined} send - IPC sender to the - * parent (`process.send` in the fork); `undefined` when there is no IPC channel. + * @param {WorkerMessageSender|undefined} send - IPC sender to the parent (`process.send` in the + * fork); `undefined` when there is no IPC channel. * @returns {Promise} The process exit code: `0` on success, `1` on failure. * @example * const code = await runTypeGeneration(process.env.SLOTHLET_CONFIG, process.send?.bind(process)); @@ -69,8 +83,8 @@ export async function runTypeGeneration(configJson, send) { * no-op. * @param {string|undefined} argv1 - The entry script path (`process.argv[1]`). * @param {string|undefined} configJson - The serialized worker config (`SLOTHLET_CONFIG`). - * @param {((message: {type: string, error?: string}) => void)|undefined} send - IPC sender to the parent. - * @param {(code: number) => void} exit - Called with the exit code. + * @param {WorkerMessageSender|undefined} send - IPC sender to the parent. + * @param {WorkerExit} exit - Called with the exit code. * @returns {Promise|null} The pending run, or `null` when this module is not the entry script. * @example * runIfEntry(process.argv[1], process.env.SLOTHLET_CONFIG, process.send?.bind(process), process.exit.bind(process)); diff --git a/types/src/lib/processors/type-generation-worker.d.mts b/types/src/lib/processors/type-generation-worker.d.mts index 86e32337e..c0dcea8f2 100644 --- a/types/src/lib/processors/type-generation-worker.d.mts +++ b/types/src/lib/processors/type-generation-worker.d.mts @@ -1,31 +1,48 @@ +/** + * IPC sender to the parent process (`process.send` in the fork). + * @callback WorkerMessageSender + * @param {{type: string, error: (string|undefined)}} message - `{ type: "success" }` or `{ type: "error", error }`. + * @returns {void} + */ +/** + * Receives the worker's exit code (`process.exit` in the fork). + * @callback WorkerExit + * @param {number} code - `0` on success, `1` on failure. + * @returns {void} + */ /** * Build the api described by `configJson` and write its declaration file. * @param {string|undefined} configJson - The serialized worker config (`SLOTHLET_CONFIG`): the * slothlet config for an eager, fast-mode instance plus `types` (`{ output, interfaceName, ... }`). - * @param {((message: {type: string, error?: string}) => void)|undefined} send - IPC sender to the - * parent (`process.send` in the fork); `undefined` when there is no IPC channel. + * @param {WorkerMessageSender|undefined} send - IPC sender to the parent (`process.send` in the + * fork); `undefined` when there is no IPC channel. * @returns {Promise} The process exit code: `0` on success, `1` on failure. * @example * const code = await runTypeGeneration(process.env.SLOTHLET_CONFIG, process.send?.bind(process)); */ -export function runTypeGeneration(configJson: string | undefined, send: ((message: { - type: string; - error?: string; -}) => void) | undefined): Promise; +export function runTypeGeneration(configJson: string | undefined, send: WorkerMessageSender | undefined): Promise; /** * Run the type generation and hand its exit code to `exit`, but only when `argv1` is this file — * i.e. when the module is the process's entry script (the strict-mode fork). Any other import is a * no-op. * @param {string|undefined} argv1 - The entry script path (`process.argv[1]`). * @param {string|undefined} configJson - The serialized worker config (`SLOTHLET_CONFIG`). - * @param {((message: {type: string, error?: string}) => void)|undefined} send - IPC sender to the parent. - * @param {(code: number) => void} exit - Called with the exit code. + * @param {WorkerMessageSender|undefined} send - IPC sender to the parent. + * @param {WorkerExit} exit - Called with the exit code. * @returns {Promise|null} The pending run, or `null` when this module is not the entry script. * @example * runIfEntry(process.argv[1], process.env.SLOTHLET_CONFIG, process.send?.bind(process), process.exit.bind(process)); */ -export function runIfEntry(argv1: string | undefined, configJson: string | undefined, send: ((message: { +export function runIfEntry(argv1: string | undefined, configJson: string | undefined, send: WorkerMessageSender | undefined, exit: WorkerExit): Promise | null; +/** + * IPC sender to the parent process (`process.send` in the fork). + */ +export type WorkerMessageSender = (message: { type: string; - error?: string; -}) => void) | undefined, exit: (code: number) => void): Promise | null; + error: (string | undefined); +}) => void; +/** + * Receives the worker's exit code (`process.exit` in the fork). + */ +export type WorkerExit = (code: number) => void; //# sourceMappingURL=type-generation-worker.d.mts.map \ No newline at end of file diff --git a/types/src/lib/processors/type-generation-worker.d.mts.map b/types/src/lib/processors/type-generation-worker.d.mts.map index 74e95e5dc..0c8592893 100644 --- a/types/src/lib/processors/type-generation-worker.d.mts.map +++ b/types/src/lib/processors/type-generation-worker.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"type-generation-worker.d.mts","sourceRoot":"","sources":["../../../../src/lib/processors/type-generation-worker.mjs"],"names":[],"mappings":"AAoCA;;;;;;;;;GASG;AACH,8CARW,MAAM,GAAC,SAAS,QAEhB,CAAC,CAAC,OAAO,EAAE;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAC,KAAK,IAAI,CAAC,GAAC,SAAS,GAE3D,OAAO,CAAC,MAAM,CAAC,CAqB3B;AAED;;;;;;;;;;;GAWG;AACH,kCARW,MAAM,GAAC,SAAS,cAChB,MAAM,GAAC,SAAS,QAChB,CAAC,CAAC,OAAO,EAAE;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAC,KAAK,IAAI,CAAC,GAAC,SAAS,QAC7D,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,GACpB,OAAO,CAAC,IAAI,CAAC,GAAC,IAAI,CAS9B"} \ No newline at end of file +{"version":3,"file":"type-generation-worker.d.mts","sourceRoot":"","sources":["../../../../src/lib/processors/type-generation-worker.mjs"],"names":[],"mappings":"AAoCA;;;;;GAKG;AAEH;;;;;GAKG;AAEH;;;;;;;;;GASG;AACH,8CARW,MAAM,GAAC,SAAS,QAEhB,mBAAmB,GAAC,SAAS,GAE3B,OAAO,CAAC,MAAM,CAAC,CAqB3B;AAED;;;;;;;;;;;GAWG;AACH,kCARW,MAAM,GAAC,SAAS,cAChB,MAAM,GAAC,SAAS,QAChB,mBAAmB,GAAC,SAAS,QAC7B,UAAU,GACR,OAAO,CAAC,IAAI,CAAC,GAAC,IAAI,CAS9B;;;;4CAzDU;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,CAAC,MAAM,GAAC,SAAS,CAAC,CAAA;CAAC,KACvC,IAAI;;;;gCAMN,MAAM,KACJ,IAAI"} \ No newline at end of file From a7c1fe5ece112fa1b790ff9d0544ff04e9297e0f Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 28 Sep 2026 11:54:47 -0700 Subject: [PATCH 11/49] test: assert resolve-from-caller returns the test file's directory, not a path containing "slothlet" The slothletIndex === -1 case checked expect(result).toContain("slothlet"), which tests the checkout's folder name: it failed in every clone outside a "slothlet" folder (the full-suite runs for #501 and #505) and would pass for a wrong directory whose path contains the word. Assert the real expectation, the directory holding the test file. Fixes #506 --- .../suites/helpers/resolve-from-caller.test.vitest.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/vitests/suites/helpers/resolve-from-caller.test.vitest.mjs b/tests/vitests/suites/helpers/resolve-from-caller.test.vitest.mjs index ab52a72a1..ee326ac9a 100644 --- a/tests/vitests/suites/helpers/resolve-from-caller.test.vitest.mjs +++ b/tests/vitests/suites/helpers/resolve-from-caller.test.vitest.mjs @@ -65,8 +65,9 @@ describe("Resolver.resolvePathFromCaller", () => { const result = resolver.resolvePathFromCaller("."); expect(typeof result).toBe("string"); expect(result.length).toBeGreaterThan(0); - // The resolved "." should point to a directory that contains this test file - expect(result).toContain("slothlet"); + // The resolved "." is the directory that contains this test file. (Asserting on the + // checkout path's text instead fails in any clone not under a "slothlet" folder, #506.) + expect(result).toBe(__dirname); }); // ─── Caller-dir resolution succeeds (lines 193-194) ──────────────────── From b0c5ef799d38236ea6ed67c7e6fd3f1b3b10cab3 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 28 Sep 2026 07:10:23 -0700 Subject: [PATCH 12/49] feat(typegen): type the composed api, self, and slothlet() from each node's module origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typegen guessed each api node's type by matching the last path segment against a file's ESM export syntax, so CommonJS leaves, default exports, flattened and hoisted members, object-leaf methods and non-function values came out as `any` or were missing, and the generated `declare const self` was module-scoped and could not type an imported `self`. The builder now records where each node came from. Ownership entries carry an `exportPath` next to `filePath` — the access path within the file's module namespace (`["add"]`, `["default"]`, `["default", "member"]`) — captured from the loaded namespace before processModuleForAPI() merges its exports, and derived for child wrappers as they are created, in eager mode, lazy materialization, reload and api.slothlet.api.add() mounts alike. `ownership.getOrigin(apiPath)` reads it back. The type generator reads those origins from the composed instance and emits `typeof import("")[...]` for every member, so TypeScript types each leaf straight from its source, whatever the format (.mjs JSDoc, .cjs module.exports, .ts/.mts). Callable namespaces intersect the function with their members; nodes with no module origin are `unknown`, with a comment. The signature-copying machinery is gone and typegen no longer needs the `typescript` package. `@cldmv/slothlet/runtime` exports a `SlothletSelf` type anchor and types `self` as it. The generated declaration extends it with the api and the framework surface, and `slothlet()` is `(...) => Promise`, so an imported `self` and an awaited `slothlet()` are both typed with no annotation. `augmentRuntime: false` (`--no-augment-runtime`, or `slothlet.typegen.augmentRuntime` in package.json) opts out. Refs #484 --- api_tests/api_test_typegen_mixed/bulb.mts | 32 ++ api_tests/api_test_typegen_mixed/greet.mjs | 26 + api_tests/api_test_typegen_mixed/logger.mjs | 39 ++ api_tests/api_test_typegen_mixed/math.mjs | 37 ++ api_tests/api_test_typegen_mixed/parity.cjs | 26 + api_tests/api_test_typegen_mixed/settings.mjs | 29 + api_tests/api_test_typegen_mixed/text.cjs | 39 ++ .../tuning/defaults.mjs | 20 + .../api_test_typegen_mixed/tuning/tuning.mjs | 30 + .../widgets/widgets.mjs | 35 ++ bin/slothlet.mjs | 21 +- docs/TYPESCRIPT.md | 54 +- index.mjs | 14 +- src/lib/builders/modes-processor.mjs | 34 +- src/lib/handlers/ownership.mjs | 392 ++++++++++++- src/lib/handlers/unified-wrapper.mjs | 63 +++ src/lib/helpers/modes-utils.mjs | 11 +- src/lib/i18n/languages/de-de.json | 2 - src/lib/i18n/languages/en-gb.json | 2 - src/lib/i18n/languages/en-us.json | 2 - src/lib/i18n/languages/es-es.json | 2 - src/lib/i18n/languages/es-mx.json | 2 - src/lib/i18n/languages/fr-fr.json | 2 - src/lib/i18n/languages/hi-in.json | 2 - src/lib/i18n/languages/ja-jp.json | 2 - src/lib/i18n/languages/ko-kr.json | 2 - src/lib/i18n/languages/pt-br.json | 2 - src/lib/i18n/languages/ru-ru.json | 2 - src/lib/i18n/languages/zh-cn.json | 2 - src/lib/processors/flatten.mjs | 56 +- src/lib/processors/type-generator.mjs | 520 ++++++++---------- src/lib/runtime/runtime.mjs | 25 +- src/lib/typegen/typegen.mjs | 20 +- src/slothlet.mjs | 18 +- tests/validate-typestubs.mjs | 73 ++- .../typegen/typegen-cli.test.vitest.mjs | 39 +- .../typegen/typegen-origin.test.vitest.mjs | 357 ++++++++++++ .../typegen-programmatic.test.vitest.mjs | 165 ++++-- .../typescript/type-generator.test.vitest.mjs | 269 +++++---- .../typescript-strict-mode.test.vitest.mjs | 5 +- types/index.d.mts | 20 +- types/index.d.mts.map | 2 +- .../lib/builders/modes-processor.d.mts.map | 2 +- types/src/lib/handlers/ownership.d.mts | 171 +++++- types/src/lib/handlers/ownership.d.mts.map | 2 +- types/src/lib/handlers/unified-wrapper.d.mts | 6 +- .../lib/handlers/unified-wrapper.d.mts.map | 2 +- types/src/lib/helpers/modes-utils.d.mts.map | 2 +- types/src/lib/processors/flatten.d.mts | 13 +- types/src/lib/processors/flatten.d.mts.map | 2 +- types/src/lib/processors/type-generator.d.mts | 17 +- .../lib/processors/type-generator.d.mts.map | 2 +- types/src/lib/runtime/runtime.d.mts | 25 +- types/src/lib/runtime/runtime.d.mts.map | 2 +- types/src/lib/typegen/typegen.d.mts | 4 + types/src/lib/typegen/typegen.d.mts.map | 2 +- types/src/slothlet.d.mts | 7 +- types/src/slothlet.d.mts.map | 2 +- 58 files changed, 2185 insertions(+), 571 deletions(-) create mode 100644 api_tests/api_test_typegen_mixed/bulb.mts create mode 100644 api_tests/api_test_typegen_mixed/greet.mjs create mode 100644 api_tests/api_test_typegen_mixed/logger.mjs create mode 100644 api_tests/api_test_typegen_mixed/math.mjs create mode 100644 api_tests/api_test_typegen_mixed/parity.cjs create mode 100644 api_tests/api_test_typegen_mixed/settings.mjs create mode 100644 api_tests/api_test_typegen_mixed/text.cjs create mode 100644 api_tests/api_test_typegen_mixed/tuning/defaults.mjs create mode 100644 api_tests/api_test_typegen_mixed/tuning/tuning.mjs create mode 100644 api_tests/api_test_typegen_mixed/widgets/widgets.mjs create mode 100644 tests/vitests/suites/typegen/typegen-origin.test.vitest.mjs diff --git a/api_tests/api_test_typegen_mixed/bulb.mts b/api_tests/api_test_typegen_mixed/bulb.mts new file mode 100644 index 000000000..02ec6b4c4 --- /dev/null +++ b/api_tests/api_test_typegen_mixed/bulb.mts @@ -0,0 +1,32 @@ +/** + * @fileoverview TypeScript leaf: an interface-annotated object leaf and a function returning a + * `node:events` type (typegen mixed-tree fixture, #484). + * @module api_test_typegen_mixed.bulb + */ +import { EventEmitter } from "node:events"; + +/** Shape of a bulb's controls. */ +export interface BulbApi { + brightness: { + get(): number; + set(level: number): void; + }; +} + +let level = 50; + +/** Brightness control, typed through the `BulbApi` interface. */ +export const brightness: BulbApi["brightness"] = { + get: () => level, + set: (next: number) => { + level = next; + } +}; + +/** + * Create an event source for bulb changes. + * @returns A fresh emitter. + */ +export function events(): EventEmitter { + return new EventEmitter(); +} diff --git a/api_tests/api_test_typegen_mixed/greet.mjs b/api_tests/api_test_typegen_mixed/greet.mjs new file mode 100644 index 000000000..cdff3495a --- /dev/null +++ b/api_tests/api_test_typegen_mixed/greet.mjs @@ -0,0 +1,26 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_typegen_mixed/greet.mjs + * @Date: 2026-09-28 00:01:45 -07:00 (1790578905) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:49:32 -07:00 (1790581772) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview A default-export function (typegen mixed-tree fixture, #484). + * @module api_test_typegen_mixed.greet + */ + +/** + * Greet someone by name. + * @param {string} name - Who to greet. + * @returns {string} The greeting. + */ +export default function greet(name) { + return `Hello, ${name}`; +} diff --git a/api_tests/api_test_typegen_mixed/logger.mjs b/api_tests/api_test_typegen_mixed/logger.mjs new file mode 100644 index 000000000..a89917a8c --- /dev/null +++ b/api_tests/api_test_typegen_mixed/logger.mjs @@ -0,0 +1,39 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_typegen_mixed/logger.mjs + * @Date: 2026-09-28 00:01:45 -07:00 (1790578905) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:49:33 -07:00 (1790581773) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Callable container: a default function with named members merged onto it + * (typegen mixed-tree fixture, #484). + * @module api_test_typegen_mixed.logger + */ + +/** + * Log a message. + * @param {string} message - Message to log. + * @returns {string} The formatted line. + */ +export default function logger(message) { + return `[log] ${message}`; +} + +/** + * Log an informational message. + * @param {string} message - Message to log. + * @returns {string} The formatted line. + */ +export function info(message) { + return `[info] ${message}`; +} + +/** Numeric log level. */ +export const LEVEL = 2; diff --git a/api_tests/api_test_typegen_mixed/math.mjs b/api_tests/api_test_typegen_mixed/math.mjs new file mode 100644 index 000000000..384df0a02 --- /dev/null +++ b/api_tests/api_test_typegen_mixed/math.mjs @@ -0,0 +1,37 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_typegen_mixed/math.mjs + * @Date: 2026-09-28 00:01:45 -07:00 (1790578905) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:49:33 -07:00 (1790581773) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Named-export functions typed with JSDoc (typegen mixed-tree fixture, #484). + * @module api_test_typegen_mixed.math + */ + +/** + * Add two numbers. + * @param {number} a - First addend. + * @param {number} b - Second addend. + * @returns {number} The sum. + */ +export function add(a, b) { + return a + b; +} + +/** + * Repeat a string. + * @param {string} text - Text to repeat. + * @param {number} [times=2] - Repeat count. + * @returns {string} The repeated text. + */ +export function repeat(text, times = 2) { + return text.repeat(times); +} diff --git a/api_tests/api_test_typegen_mixed/parity.cjs b/api_tests/api_test_typegen_mixed/parity.cjs new file mode 100644 index 000000000..cb3f47247 --- /dev/null +++ b/api_tests/api_test_typegen_mixed/parity.cjs @@ -0,0 +1,26 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_typegen_mixed/parity.cjs + * @Date: 2026-09-28 00:01:45 -07:00 (1790578905) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:49:33 -07:00 (1790581773) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview CommonJS function export via `module.exports = function` (typegen mixed-tree fixture, #484). + * @module api_test_typegen_mixed.parity + */ + +/** + * Whether a number is even. + * @param {number} n - Number to test. + * @returns {boolean} True when even. + */ +module.exports = function parity(n) { + return n % 2 === 0; +}; diff --git a/api_tests/api_test_typegen_mixed/settings.mjs b/api_tests/api_test_typegen_mixed/settings.mjs new file mode 100644 index 000000000..623f78495 --- /dev/null +++ b/api_tests/api_test_typegen_mixed/settings.mjs @@ -0,0 +1,29 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_typegen_mixed/settings.mjs + * @Date: 2026-09-28 00:01:45 -07:00 (1790578905) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:49:33 -07:00 (1790581773) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Non-function exports: a frozen constant object and a primitive (typegen mixed-tree fixture, #484). + * @module api_test_typegen_mixed.settings + */ + +/** + * Retry limits. + * @type {Readonly<{ maxRetries: number, mode: "strict" | "lenient" }>} + */ +export const LIMITS = Object.freeze({ maxRetries: 3, mode: "strict" }); + +/** + * Settings schema version. + * @type {string} + */ +export const VERSION = "1.2.0"; diff --git a/api_tests/api_test_typegen_mixed/text.cjs b/api_tests/api_test_typegen_mixed/text.cjs new file mode 100644 index 000000000..c1338990c --- /dev/null +++ b/api_tests/api_test_typegen_mixed/text.cjs @@ -0,0 +1,39 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_typegen_mixed/text.cjs + * @Date: 2026-09-28 00:01:45 -07:00 (1790578905) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:49:33 -07:00 (1790581773) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview CommonJS object export: a function and an object leaf (typegen mixed-tree fixture, #484). + * @module api_test_typegen_mixed.text + */ + +/** + * Upper-case a string. + * @param {string} value - Text to shout. + * @returns {string} The upper-cased text. + */ +function shout(value) { + return value.toUpperCase(); +} + +const stats = { + /** + * Count the words in a string. + * @param {string} value - Text to count. + * @returns {number} Word count. + */ + words(value) { + return value.split(/\s+/).filter(Boolean).length; + } +}; + +module.exports = { shout, stats }; diff --git a/api_tests/api_test_typegen_mixed/tuning/defaults.mjs b/api_tests/api_test_typegen_mixed/tuning/defaults.mjs new file mode 100644 index 000000000..dcb693a9b --- /dev/null +++ b/api_tests/api_test_typegen_mixed/tuning/defaults.mjs @@ -0,0 +1,20 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_typegen_mixed/tuning/defaults.mjs + * @Date: 2026-09-28 05:49:33 -07:00 (1790599773) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 05:58:46 -07:00 (1790600326) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Sibling of the self-named tuning file (typegen mixed-tree fixture, #484). + * @module api_test_typegen_mixed.tuning.defaults + */ + +/** Default gain. */ +export const DEFAULT_GAIN = 2; diff --git a/api_tests/api_test_typegen_mixed/tuning/tuning.mjs b/api_tests/api_test_typegen_mixed/tuning/tuning.mjs new file mode 100644 index 000000000..7b74dadca --- /dev/null +++ b/api_tests/api_test_typegen_mixed/tuning/tuning.mjs @@ -0,0 +1,30 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_typegen_mixed/tuning/tuning.mjs + * @Date: 2026-09-28 05:49:33 -07:00 (1790599773) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 05:58:46 -07:00 (1790600326) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Self-named file in a folder with a sibling file: its named exports — including a + * primitive — merge into the folder namespace alongside the sibling's (typegen mixed-tree fixture, #484). + * @module api_test_typegen_mixed.tuning + */ + +/** + * Scale a value by the tuning step. + * @param {number} value - Value to scale. + * @returns {number} The scaled value. + */ +export function tune(value) { + return value * TUNE_STEP; +} + +/** Tuning step size. */ +export const TUNE_STEP = 5; diff --git a/api_tests/api_test_typegen_mixed/widgets/widgets.mjs b/api_tests/api_test_typegen_mixed/widgets/widgets.mjs new file mode 100644 index 000000000..bb29a79f8 --- /dev/null +++ b/api_tests/api_test_typegen_mixed/widgets/widgets.mjs @@ -0,0 +1,35 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_typegen_mixed/widgets/widgets.mjs + * @Date: 2026-09-28 00:01:45 -07:00 (1790578905) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:49:33 -07:00 (1790581773) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Self-named file inside its own folder — flattens into the folder namespace + * (typegen mixed-tree fixture, #484). + * @module api_test_typegen_mixed.widgets + */ + +/** + * Create a widget. + * @param {string} name - Widget name. + * @returns {{ id: number, name: string }} The widget. + */ +export function create(name) { + return { id: name.length, name }; +} + +/** + * Count widgets. + * @returns {number} Always zero in this fixture. + */ +export function count() { + return 0; +} diff --git a/bin/slothlet.mjs b/bin/slothlet.mjs index 167facd91..aaae6e335 100755 --- a/bin/slothlet.mjs +++ b/bin/slothlet.mjs @@ -81,14 +81,16 @@ function printTypegenHelp() { slothlet typegen # reads from "slothlet.typegen" in package.json Generates a TypeScript .d.ts file describing the API loaded from . -The file declares an interface named and a 'self' constant of -that interface type, so '.ts' modules in your API can use 'self.*' with full -autocomplete and type-checking. +The file declares an interface named and extends 'SlothletSelf' +from '@cldmv/slothlet/runtime' with it, so every module that imports 'self' +(.ts, .mts, or JSDoc-checked .mjs/.cjs) gets full autocomplete and +type-checking on 'self.*'. Include the generated file in your tsconfig. Options: -d, --dir Path to the API directory -o, --output Output path for the generated .d.ts -n, --interface-name Name of the generated TypeScript interface + --no-augment-runtime Don't type 'self' from this declaration -h, --help Show this help Resolution order (per option): flag → positional → package.json's "slothlet.typegen". @@ -147,7 +149,7 @@ async function runTypegen(args) { * Parse typegen flags and positional args. Throws on flag-without-value. * Unknown long flags throw; unknown positional args are stored but later checks decide what to do. * @param {string[]} args - * @returns {{dir?: string, output?: string, interfaceName?: string}} + * @returns {{dir?: string, output?: string, interfaceName?: string, augmentRuntime?: boolean}} * @private */ function parseTypegenArgs(args) { @@ -161,6 +163,8 @@ function parseTypegenArgs(args) { opts.output = requireValue(args, ++i, arg); } else if (arg === "-n" || arg === "--interface-name") { opts.interfaceName = requireValue(args, ++i, arg); + } else if (arg === "--no-augment-runtime") { + opts.augmentRuntime = false; } else if (arg.startsWith("-")) { throw new Error(`unknown option '${arg}'`); } else { @@ -192,12 +196,12 @@ function requireValue(args, idx, flagName) { /** * Fill missing options from `package.json` → `slothlet.typegen`. Missing or * unparseable package.json is treated as an empty source (no error). - * @param {{dir?: string, output?: string, interfaceName?: string}} opts - * @returns {{dir?: string, output?: string, interfaceName?: string}} + * @param {{dir?: string, output?: string, interfaceName?: string, augmentRuntime?: boolean}} opts + * @returns {{dir?: string, output?: string, interfaceName?: string, augmentRuntime?: boolean}} * @private */ function mergeWithPackageJson(opts) { - if (opts.dir && opts.output && opts.interfaceName) return opts; + if (opts.dir && opts.output && opts.interfaceName && opts.augmentRuntime !== undefined) return opts; const pkgPath = path.resolve(process.cwd(), "package.json"); if (!fs.existsSync(pkgPath)) return opts; @@ -213,6 +217,7 @@ function mergeWithPackageJson(opts) { return { dir: opts.dir ?? fromPkg.dir, output: opts.output ?? fromPkg.output, - interfaceName: opts.interfaceName ?? fromPkg.interfaceName + interfaceName: opts.interfaceName ?? fromPkg.interfaceName, + augmentRuntime: opts.augmentRuntime ?? fromPkg.augmentRuntime }; } diff --git a/docs/TYPESCRIPT.md b/docs/TYPESCRIPT.md index 0cbcdf5fc..0d2a76745 100644 --- a/docs/TYPESCRIPT.md +++ b/docs/TYPESCRIPT.md @@ -228,27 +228,51 @@ The `.d.ts` file is generated **before** modules are loaded, so TypeScript files ### Example Generated Output -For an API directory containing `math.ts` and `string.ts`: +For an API directory containing: +```text +api/ +├── math.ts export function add(a: number, b: number): number +├── logger.mjs export default function logger(msg) + export function info(msg) +├── parity.cjs module.exports = function parity(n) { … } +└── text.cjs module.exports = { shout, stats } +``` + +the generated file references each export slothlet placed at each path: + + ```typescript // types/api.d.ts (generated) +import type { SlothletAPI } from "@cldmv/slothlet"; + export interface MyAPI { math: { - add(a: number, b: number): number; - subtract(a: number, b: number): number; - multiply(a: number, b: number): number; + add: typeof import("../api/math.js")["add"]; }; - string: { - capitalize(str: string): string; - lowercase(str: string): string; - uppercase(str: string): string; + logger: typeof import("../api/logger.mjs")["default"] & { + info: typeof import("../api/logger.mjs")["info"]; }; + parity: typeof import("../api/parity.cjs"); + text: typeof import("../api/text.cjs"); } -declare const self: MyAPI; +declare module "@cldmv/slothlet/runtime" { + interface SlothletSelf extends MyAPI {} + interface SlothletSelf extends SlothletAPI {} +} ``` -> **Both JavaScript and TypeScript leaves are typed faithfully.** Signatures are resolved through the TypeScript checker. A JavaScript leaf documented with JSDoc — `@param {string} name`, `@returns {Promise}`, object shapes from a `@param {object} opts` tag plus its dotted `opts.id` sub-tags, optional params, unions, and generics — is emitted with those real types, not `any`. A TypeScript leaf is typed from its annotations — in a `.ts` / `.mts` file JSDoc _type_ tags are ignored, exactly as `tsc` ignores them, so annotate the types there — and any local `interface` / `type` / `enum` it references is emitted alongside the declaration (via TypeScript's own declaration emitter) so the output compiles standalone. +| Composed node | Generated type | +| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | +| A named export (`export function add`) | `typeof import("")["add"]` | +| A default export | `typeof import("")["default"]` | +| A member of an exported object | `typeof import("")["obj"]["member"]` | +| A CommonJS `module.exports` / one of its members | `typeof import(".cjs")` / `typeof import(".cjs")["member"]` | +| A namespace slothlet composed (a folder, a file's named exports) | an object type of its members | +| A callable namespace (a default function with named exports merged onto it) | the function's type `&` an object of the merged members | +| A member with no module origin (a runtime `self.X = value`, in-memory `api.add()` exports) | `unknown`, with a comment saying why | + +> **Every leaf is typed by its own source.** Each member is `typeof import(...)` of the export slothlet actually placed there, so TypeScript reads the leaf's own types — JSDoc in `.mjs` / `.cjs`, annotations in `.ts` / `.mts` (including types it imports, such as `node:events`), frozen constants, object leaves typed through an interface. The origin comes from composition itself (slothlet records which export produced each api node), so flattened, hoisted, and merged members are typed correctly too. Specifiers are relative to the generated file and use each leaf's runtime extension (`.ts` → `.js`, `.mts` → `.mjs`), which resolves under both `NodeNext` and `Bundler`. Because JavaScript leaves are referenced directly, the program that includes the declaration needs `allowJs: true`. ### Runtime Imports (`self`, `context`, `instanceID`) @@ -265,7 +289,7 @@ export function fullReport(name: string) { } ``` -The generated `.d.ts` additionally includes `declare const self: InterfaceName` whenever `types.interfaceName` is set, which gives `self` full autocomplete and type-checking against your API shape. `context` and `instanceID` are typed by the runtime module's own `.d.ts` (no extra config needed). +`self` is declared as `SlothletSelf`, an empty class exported by `@cldmv/slothlet/runtime`. The generated `.d.ts` extends that class with the generated interface (and with `SlothletAPI`, since `self.slothlet.*` is the same framework surface as `api.slothlet.*`), which gives `self` full autocomplete and type-checking against your API shape in every leaf that imports it — `.ts`, `.mts`, and JSDoc-checked `.mjs` / `.cjs` alike. The same anchor types the consumer side: `slothlet()` resolves to `SlothletAPI & T`, where `T` defaults to `SlothletSelf`, so `const api = await slothlet({ base: "./api" })` is typed with no annotation. A program that loads more than one api passes its interface explicitly (`slothlet({ base: "./other" })`) and generates the other declarations with `augmentRuntime: false` (`--no-augment-runtime`). `context` and `instanceID` are typed by the runtime module's own `.d.ts` (no extra config needed). See [CONTEXT-PROPAGATION.md](CONTEXT-PROPAGATION.md) for what `context` and `instanceID` carry and how they propagate across calls. @@ -349,11 +373,11 @@ const { filePath, content } = await generateTypes({ console.log(`Wrote ${filePath} (${content.length} bytes)`); ``` -`generateTypes()` loads the API in eager + fast TypeScript mode internally, walks the resulting structure, extracts type info from your source files via the TypeScript compiler API, writes the `.d.ts`, and shuts the loaded instance down before returning. If `dir`, `output`, or `interfaceName` is missing or empty, it throws `SlothletError("INVALID_CONFIG")` — the same error class everything else uses. +`generateTypes()` loads the API in eager + fast TypeScript mode internally, walks the composed structure, references each node's originating export (see [Example Generated Output](#example-generated-output)), writes the `.d.ts`, and shuts the loaded instance down before returning. It does not need the `typescript` package. If `dir`, `output`, or `interfaceName` is missing or empty, it throws `SlothletError("INVALID_CONFIG")` — the same error class everything else uses. ### What the output looks like -The generated file matches what strict mode emits — see [Example Generated Output](#example-generated-output) above. Concretely: a top-level `interface`, a `declare const self: `, and JSDoc comments preserved from your sources. +The generated file matches what strict mode emits — see [Example Generated Output](#example-generated-output) above. Concretely: a top-level `interface` whose members are `typeof import(...)` references to your leaves, and a `declare module "@cldmv/slothlet/runtime"` block that extends `SlothletSelf` (omitted with `augmentRuntime: false`). ### Editor / build wiring tips @@ -427,7 +451,7 @@ SlothletError: TypeScript strict mode requires 'types.interfaceName' to be confi - **No `.tsx` support.** Only `.ts` and `.mts` files are handled. JSX is not supported. - **Fast mode does not type-check.** Type errors are silently ignored. Use strict mode if you need type validation. -- **Type generation reads from API metadata.** Function signatures in the `.d.ts` are extracted from TypeScript source files using the TypeScript Compiler API. Plain JavaScript files get generic signatures in the generated declaration. -- **`self` is a runtime concept.** The `declare const self: InterfaceName` in the generated `.d.ts` provides type information only. The actual `self` value at runtime is the Slothlet proxy, imported via `import { self } from "@cldmv/slothlet/runtime"` and resolved per-instance through the module context system. +- **Type generation reads the composed api.** Each member of the `.d.ts` references the export slothlet placed at that path; a member with no module origin (assigned at runtime, or mounted from in-memory `api.add()` exports) is typed `unknown`. JavaScript leaves are referenced directly, so compile the declaration with `allowJs: true`. +- **`self` is a runtime concept.** The `SlothletSelf` augmentation in the generated `.d.ts` provides type information only. The actual `self` value at runtime is the Slothlet proxy, imported via `import { self } from "@cldmv/slothlet/runtime"` and resolved per-instance through the module context system. - **Generated `.d.ts` is not automatically deleted.** Slothlet does not clean up generated files after shutdown. Manage the file lifecycle yourself or use a temp directory. - **Transformed TS is cached on disk.** Slothlet writes transformed `.ts`/`.mts` output to `/.slothlet-cache/-/.mjs` so Node's resolver can anchor bare-specifier imports (e.g. `@cldmv/slothlet/runtime`). Relative specifiers in the transformed output are anchored at the original source directory — relative imports of plain `.mjs` / `.cjs` / `.js` files become absolute `file://` URLs, and relative imports of other `.ts` / `.mts` modules are transpiled and linked to their own cache files (import cycles included). The current instance's directory is removed on `shutdown()`. On the first TS load each process also passively sweeps sibling directories whose `` prefix no longer matches a live process (probed via signal 0 — nothing is killed), preventing orphans from accumulating when a process exits without calling `shutdown()` (SIGKILL, OOM, crash, etc.). Add `.slothlet-cache/` to your `.gitignore`. diff --git a/index.mjs b/index.mjs index ec7d97a93..d81cbaa95 100644 --- a/index.mjs +++ b/index.mjs @@ -104,14 +104,26 @@ const devcheckPromise = __isNode })() : Promise.resolve(); +/** + * The framework surface of a composed api: `api.slothlet.*`, `api.shutdown()`, `api.destroy()`. + * `slothlet typegen` output extends `SlothletSelf` with it, so `self.slothlet.*` is typed too. + * @typedef {import("./src/slothlet.mjs").SlothletAPI} SlothletAPI + */ + /** * Creates a slothlet API instance with live-binding context and AsyncLocalStorage support. * Automatically wraps all API functions with context isolation for multi-instance support. + * + * The resolved api is typed as `SlothletAPI & T`. `T` defaults to `SlothletSelf` from + * `@cldmv/slothlet/runtime`, which a `slothlet typegen` declaration extends with the project's api — + * so a project with generated types gets a typed api with no annotation. A program that loads more + * than one api passes the interface explicitly: `slothlet({ base: "./other" })`. * @public * @async * + * @template {object} [T=import("./src/lib/runtime/runtime.mjs").SlothletSelf] * @param {import("./src/slothlet.mjs").SlothletOptions} [options={}] - Configuration options for the slothlet instance. See {@link SlothletOptions} for the full set. - * @returns {Promise} The bound API object with management methods + * @returns {Promise} The bound API object with management methods * * @example // ESM * import slothlet from "@cldmv/slothlet"; diff --git a/src/lib/builders/modes-processor.mjs b/src/lib/builders/modes-processor.mjs index 2f874cb58..2d22e312e 100644 --- a/src/lib/builders/modes-processor.mjs +++ b/src/lib/builders/modes-processor.mjs @@ -451,11 +451,21 @@ export class ModesProcessor extends ComponentBase { // Synthetic / in-memory leaf (#117): exports are supplied directly on the file // entry, so skip the disk load + extractExports. Everything downstream (flatten, // wrap, assign) operates on the same `{ default?, ...named }` shape a file produces. - const exports = file.synthetic - ? file.exports - : this.slothlet.processors.loader.extractExports( - await this.slothlet.processors.loader.loadModule(file.path, this.slothlet.instanceID, moduleID, cacheBust) - ); + let exports; + if (file.synthetic) { + exports = file.exports; + } else { + const loadedNamespace = await this.slothlet.processors.loader.loadModule( + file.path, + this.slothlet.instanceID, + moduleID, + cacheBust + ); + // Index where each exported value sits in the namespace, so every wrapper built from this + // file can record its module origin (#484). + this.slothlet.handlers.ownership?.indexModuleExports(file.path, loadedNamespace); + exports = this.slothlet.processors.loader.extractExports(loadedNamespace); + } const moduleName = this.slothlet.helpers.sanitize.sanitizePropertyName(file.name); const moduleKeys = Object.keys(exports).filter((k) => k !== "default"); const analysis = { @@ -515,12 +525,16 @@ export class ModesProcessor extends ComponentBase { if (isRootContributor) { // Build the function with named exports attached const defaultFunc = this.slothlet.helpers.modesUtils.ensureNamedExportFunction(mod.default, moduleName); + const rootMembers = {}; for (const key of moduleKeys) { if (!this.slothlet.processors.flatten.shouldAttachNamedExport(key, mod[key], defaultFunc, mod.default)) { continue; } defaultFunc[key] = mod[key]; + rootMembers[key] = [key]; } + // The attached named exports' origins, for the callable's children (#484). + this.slothlet.handlers.ownership?.recordComposedContent(file.path, defaultFunc, rootMembers, ["default"]); // Track root-level default function exports for post-processing rootContributors.push({ moduleName, file, defaultFunc }); continue; // Skip normal processing for root contributors @@ -548,7 +562,7 @@ export class ModesProcessor extends ComponentBase { const effectiveCategoryName = categoryName || moduleName; // Build module content based on decision (C08-C09b + AddApi + collision handling) - let { moduleContent } = this.slothlet.processors.flatten.processModuleForAPI({ + let { moduleContent, origins: moduleOrigins } = this.slothlet.processors.flatten.processModuleForAPI({ mod, decision, moduleName, @@ -564,6 +578,11 @@ export class ModesProcessor extends ComponentBase { // (#372/#373 review, suppressed finding). collisionModeOverride: modes_effectiveCollisionMode }); + // Where the composed content and each key it gathered came from (#484). A synthetic + // in-memory file has no module origin and is never indexed, so this is a no-op for it. + if (!file.synthetic) { + this.slothlet.handlers.ownership?.recordComposedContent(file.path, moduleContent, moduleOrigins.members, moduleOrigins.self); + } // Special case: folder/folder.mjs pattern (only for nested, not root). Depth — not the // presence of a prefix — is what scopes it: an api.add() build carries its mount prefix at // EVERY level, and suppressing the hoist for all of them composed the same directory to a @@ -1529,6 +1548,7 @@ export class ModesProcessor extends ComponentBase { filenameMatches: filenameMatchesFolder }); const mod = await this.slothlet.processors.loader.loadModule(file.path, this.slothlet.instanceID, moduleID, cacheBust); + this.slothlet.handlers.ownership?.indexModuleExports(file.path, mod); const exports = this.slothlet.processors.loader.extractExports(mod); const moduleKeys = Object.keys(exports).filter((k) => k !== "default"); const analysis = { @@ -2164,6 +2184,7 @@ export class ModesProcessor extends ComponentBase { const filenameMatchesFolder = moduleName === categoryName; if (isGeneric || filenameMatchesFolder) { const mod = await this.slothlet.processors.loader.loadModule(file.path, this.slothlet.instanceID, moduleID, cacheBust); + this.slothlet.handlers.ownership?.indexModuleExports(file.path, mod); const exports = this.slothlet.processors.loader.extractExports(mod); const moduleKeys = Object.keys(exports).filter((k) => k !== "default"); const analysis = { @@ -2283,6 +2304,7 @@ export class ModesProcessor extends ComponentBase { source: "lazy-materialization", moduleID: moduleID, filePath: file.path, + exportPath: this.slothlet.handlers.ownership?.resolveExportPath(file.path, value) ?? null, // sourceFolder is always passed by every call site; config?.dir fallback is never evaluated. /* v8 ignore next */ sourceFolder: sourceFolder || this.slothlet.config?.dir diff --git a/src/lib/handlers/ownership.mjs b/src/lib/handlers/ownership.mjs index 0d37f62a9..dfb76d0fa 100644 --- a/src/lib/handlers/ownership.mjs +++ b/src/lib/handlers/ownership.mjs @@ -69,6 +69,89 @@ const REGISTRATION_SOURCE_CONFIRM = "subtree-confirm"; */ const REGISTRATION_SOURCE_AUTHORITATIVE = "core"; +/** + * Whether a value can key a WeakMap (an object or function). + * @param {*} value - Value to test. + * @returns {boolean} True for a non-null object or a function. + * @private + */ +function isIndexable(value) { + return (typeof value === "object" && value !== null) || typeof value === "function"; +} + +/** + * Own enumerable string keys of `value` whose descriptor is a plain data property — an accessor is + * skipped so indexing a module's exports never runs user getter code. + * @param {object|Function} value - Value whose keys to list. + * @returns {string[]} Data-property keys. + * @private + */ +function ownDataKeys(value) { + const keys = []; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== "string") continue; + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor && descriptor.enumerable && "value" in descriptor) keys.push(key); + } catch { + // A namespace binding still in its temporal dead zone throws on inspection — skip it. + } + } + return keys; +} + +/** + * Enumerable own string keys of a module namespace, tolerating a binding still in its temporal dead + * zone (inspecting one throws during a circular import). + * @param {object} mod - Module namespace. + * @returns {string[]} Binding names. + * @private + */ +function namespaceKeys(mod) { + const keys = []; + for (const key of Reflect.ownKeys(mod)) { + if (typeof key !== "string") continue; + try { + if (Object.getOwnPropertyDescriptor(mod, key)?.enumerable) keys.push(key); + } catch { + // Temporal-dead-zone binding — skip it. + } + } + return keys; +} + +/** + * Read one module-namespace binding, tolerating a binding still in its temporal dead zone. + * @param {object} mod - Module namespace. + * @param {string} key - Binding name. + * @returns {*} The bound value, or `undefined` when unreadable. + * @private + */ +function readBinding(mod, key) { + try { + return mod[key]; + } catch { + return undefined; + } +} + +/** + * Read an own data property without invoking a getter, tolerating a binding still in its temporal + * dead zone (a module namespace read during a circular import throws on access). + * @param {object|Function} value - Object to read from. + * @param {string} key - Property key. + * @returns {*} The property value, or `undefined` when unreadable or an accessor. + * @private + */ +function readOwnData(value, key) { + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; + } catch { + return undefined; + } +} + /** * Read `proxy[key]` for a bookkeeping walk without starting a lazy child's materialization (#462). * @@ -126,6 +209,258 @@ export class OwnershipManager extends ComponentBase { this.pathToModule = new Map(); // apiPath → Array<{moduleID, source, timestamp, value}> this._unregisteredModules = new Set(); // moduleIDs that have been explicitly unregistered this.moduleEndpoints = new Map(); // moduleID → mount endpoint (e.g. ".", "lib.config") + // filePath → { values: WeakMap, members: WeakMap } — + // where each value a loaded file exported sits inside that file's module namespace (#484). + this.exportIndex = new Map(); + // clone → the value it was cloned from, so an origin lookup sees through an eager-mode clone. + this.cloneSources = new WeakMap(); + } + + /** + * Index where each value of a freshly loaded module sits in that module's namespace (#484). + * @param {string} filePath - Absolute path of the loaded file. + * @param {object} mod - The module namespace as loaded (`import()` result, or the CJS loader's + * `{ default: module.exports, ...ownKeys }` namespace). + * @returns {void} + * @public + * + * @description + * Wrappers built from this file later look their impl up here to learn their `exportPath` — the + * access path within the namespace that produced the value (`["add"]`, `["default"]`, + * `["default", "member"]`). A named export wins over the same value reached through `default`. + * For a CommonJS module every path is rooted at `["default"]` (`module.exports`), since the + * named keys a CommonJS namespace carries are copies of `module.exports`' own keys. + * + * @example + * ownership.indexModuleExports("/abs/api/math.mjs", await import("/abs/api/math.mjs")); + */ + indexModuleExports(filePath, mod) { + if (typeof filePath !== "string" || !filePath || !isIndexable(mod)) return; + // Merge into an existing entry rather than replace it: the same file loaded again (a reload, or + // a second mount of it) yields new value identities, while wrappers from an earlier load may + // still be looked up against the earlier ones. + const isCommonJS = filePath.endsWith(".cjs") || Object.prototype.hasOwnProperty.call(mod, "module.exports"); + let entry = this.exportIndex.get(filePath); + if (!entry) { + entry = { values: new WeakMap(), members: new WeakMap(), keys: new Map(), commonJS: isCommonJS }; + this.exportIndex.set(filePath, entry); + } + // Top-level keys by NAME, with the value each held, for a value that has no identity to look up (a + // primitive — objects and functions resolve through the WeakMaps above, so they are not held here). + // The latest load of the file wins (a reload can change a primitive's value); within one load a named + // binding is recorded before, and wins over, a default-object member of the same name. + const keysThisLoad = new Set(); + const setKey = (key, value, exportPath) => { + if (isIndexable(value) || keysThisLoad.has(key)) return; + keysThisLoad.add(key); + entry.keys.set(key, { value, exportPath }); + }; + const setPath = (value, exportPath) => { + if (isIndexable(value) && !entry.values.has(value)) entry.values.set(value, exportPath); + }; + // The namespace's own bindings are read through `[[Get]]`: a real ESM namespace exposes them as + // data properties, but a loader-provided namespace (a test runner's module runner, a custom + // `config.import`) may expose each binding as an accessor. Members BELOW the namespace are read + // as plain data only, so indexing never runs a module's own getter code. + const namedKeys = isCommonJS ? [] : namespaceKeys(mod).filter((key) => key !== "default" && key !== "module.exports"); + for (const key of namedKeys) { + const value = readBinding(mod, key); + setPath(value, [key]); + setKey(key, value, [key]); + } + const defaultValue = readBinding(mod, "default"); + setPath(defaultValue, ["default"]); + if (isIndexable(defaultValue)) { + for (const key of ownDataKeys(defaultValue)) { + const value = readOwnData(defaultValue, key); + setPath(value, ["default", key]); + setKey(key, value, ["default", key]); + } + } + for (const key of namedKeys) { + const value = readBinding(mod, key); + if (!isIndexable(value)) continue; + for (const member of ownDataKeys(value)) setPath(readOwnData(value, member), [key, member]); + } + } + + /** + * Whether `filePath` was loaded as a CommonJS module (its exports are `module.exports`) (#484). + * @param {string} filePath - Absolute path of a loaded file. + * @returns {boolean} True for a CommonJS module; false for ES modules and files never indexed. + * @public + * + * @example + * ownership.isCommonJSModule("/abs/api/text.cjs"); // true + */ + isCommonJSModule(filePath) { + return this.exportIndex.get(filePath)?.commonJS === true; + } + + /** + * Record the per-key origins of a value composed from a module's exports (#484). + * @param {string} filePath - Absolute path of the file the content was built from. + * @param {object|Function} content - The composed content (a fresh namespace object, or a default + * export the named exports were attached onto). + * @param {Object|null} members - Composed key → the exportPath it came from. + * @param {string[]|null} [self=null] - The content's own exportPath, when it is itself an export. + * @returns {void} + * @public + * + * @description + * Flattening merges a module's default and named exports into one content value, so a key's + * position under that content no longer tells where it came from. Recording the map lets a + * wrapper built from the content hand each child — including a primitive child, which has no + * identity to look up — its real exportPath. + * + * @example + * ownership.recordComposedContent(file, content, { add: ["add"], VERSION: ["VERSION"] }); + */ + recordComposedContent(filePath, content, members, self = null) { + if (typeof filePath !== "string" || !isIndexable(content)) return; + const entry = this.exportIndex.get(filePath); + if (!entry) return; + if (self && !entry.values.has(content)) entry.values.set(content, self); + if (members && Object.keys(members).length > 0) { + entry.members.set(content, { ...(entry.members.get(content) || {}), ...members }); + } + } + + /** + * Remember that `clone` was cloned from `original`, so an origin lookup sees through the clone. + * @param {*} clone - The clone. + * @param {*} original - The value it was cloned from. + * @returns {void} + * @public + * + * @example + * ownership.noteClone(copy, exported); + */ + noteClone(clone, original) { + if (clone !== original && isIndexable(clone) && isIndexable(original)) this.cloneSources.set(clone, original); + } + + /** + * Look up where `value` sits in the module namespace of `filePath` (#484). + * @param {string|null} filePath - File the value was loaded from. + * @param {*} value - The value (or an eager-mode clone of it). + * @returns {string[]|null} The exportPath, or `null` when the value is not a known export of that file. + * @public + * + * @example + * ownership.resolveExportPath("/abs/api/math.mjs", addFn); // ["add"] + */ + resolveExportPath(filePath, value) { + const found = this.#lookupIndexed(filePath, value, "values"); + return found ? [...found] : null; + } + + /** + * Look up an export of `filePath` by the NAME it was placed under, confirmed by value (#484). + * @param {string|null} filePath - File the value was loaded from. + * @param {string} key - The key the value sits under in the composed api. + * @param {*} value - The value itself; must be the value that export held (`Object.is`). + * @returns {string[]|null} The exportPath, or `null` when `filePath` has no export of that name + * holding that value. + * @public + * + * @description + * For a value with no identity to look up — a primitive — merged into a namespace that several + * files compose (a folder whose files flatten into it), where no per-key map was recorded for the + * namespace itself. The value check keeps a same-named but different value from borrowing the path. + * + * @example + * ownership.resolveExportPathByKey("/abs/api/tuning/tuning.mjs", "TUNE_STEP", 5); // ["TUNE_STEP"] + */ + resolveExportPathByKey(filePath, key, value) { + if (typeof filePath !== "string") return null; + const found = this.exportIndex.get(filePath)?.keys.get(key); + return found && Object.is(found.value, value) ? [...found.exportPath] : null; + } + + /** + * Look up the per-key origins recorded for a composed content value (#484). + * @param {string|null} filePath - File the content was built from. + * @param {*} value - The content (or an eager-mode clone of it). + * @returns {Object|null} Composed key → exportPath, or `null`. + * @public + * + * @example + * ownership.resolveMemberExportPaths(file, content); // { add: ["add"] } + */ + resolveMemberExportPaths(filePath, value) { + return this.#lookupIndexed(filePath, value, "members") ?? null; + } + + /** + * Shared lookup for {@link OwnershipManager#resolveExportPath} / {@link OwnershipManager#resolveMemberExportPaths}. + * @param {string|null} filePath - File the value came from. + * @param {*} value - Value to look up; clone links are followed back to the original. + * @param {"values"|"members"} table - Which index table to read. + * @returns {*} The indexed entry, or `undefined`. + * @private + */ + #lookupIndexed(filePath, value, table) { + if (typeof filePath !== "string") return undefined; + const entry = this.exportIndex.get(filePath); + if (!entry) return undefined; + let current = value; + const seen = new Set(); + while (isIndexable(current) && !seen.has(current)) { + const found = entry[table].get(current); + if (found) return found; + seen.add(current); + current = this.cloneSources.get(current); + } + return undefined; + } + + /** + * Read where the value currently at `apiPath` came from (#484). + * @param {string} apiPath - API path to look up. + * @returns {{moduleID: string, filePath: (string|null), exportPath: (string[]|null), members: (Object|null)}|null} + * The current owner's origin, or `null` when nothing owns the path. + * @public + * + * @description + * `exportPath` is the access path within `filePath`'s module namespace that produced the value; + * `null` when the value has no module origin (a runtime `self.X = value`, a synthetic in-memory + * `api.add()`, a namespace container slothlet created). `members` carries per-key origins for a + * value composed from several exports. + * + * @example + * ownership.getOrigin("math.add"); // { moduleID, filePath: "/abs/api/math.mjs", exportPath: ["add"], members: null } + */ + getOrigin(apiPath) { + const owner = this.getCurrentOwner(apiPath); + if (!owner) return null; + return { + moduleID: owner.moduleID, + filePath: owner.filePath ?? null, + exportPath: owner.exportPath ? [...owner.exportPath] : null, + members: owner.memberExportPaths ?? null + }; + } + + /** + * Refresh the origin recorded for a module's entry at `apiPath` without touching its position, + * source, or collision state — for a wrapper whose impl arrived after it was registered (lazy + * materialization, hot reload). + * @param {string} moduleID - Module whose entry to refresh. + * @param {string} apiPath - API path of the entry. + * @param {{filePath?: (string|null), exportPath?: (string[]|null), memberExportPaths?: (Object|null)}} origin - New origin. + * @returns {void} + * @public + * + * @example + * ownership.refreshOrigin("base", "math.add", { filePath, exportPath: ["add"] }); + */ + refreshOrigin(moduleID, apiPath, origin) { + const entry = this.pathToModule.get(apiPath)?.find((candidate) => candidate.moduleID === moduleID); + if (!entry || !origin?.exportPath) return; + if (origin.filePath) entry.filePath = origin.filePath; + entry.exportPath = origin.exportPath; + entry.memberExportPaths = origin.memberExportPaths ?? null; } /** @@ -166,10 +501,25 @@ export class OwnershipManager extends ComponentBase { * @param {string} [options.collisionMode="error"] - Collision mode: skip, warn, error, merge, replace * @param {Object} [options.config] - Config object for silent mode check * @param {string} [options.filePath=null] - File path of the module source (for metadata tracking) + * @param {string[]|null} [options.exportPath=null] - Access path within `filePath`'s module namespace + * that produced `value` (`["add"]`, `["default"]`, `["default", "member"]`); `null` when the value + * has no module origin (#484). + * @param {Object|null} [options.memberExportPaths=null] - Per-key exportPaths for a + * value composed from several exports (#484). * @returns {Object|null} Registration entry or null if skipped * @public */ - register({ moduleID, apiPath, value, source = "core", collisionMode = "error", config = null, filePath = null }) { + register({ + moduleID, + apiPath, + value, + source = "core", + collisionMode = "error", + config = null, + filePath = null, + exportPath = null, + memberExportPaths = null + }) { // Validate inputs if (!moduleID || typeof moduleID !== "string") { throw new this.SlothletError("OWNERSHIP_INVALID_MODULE_ID", { moduleID }, null, { validationError: true }); @@ -242,6 +592,17 @@ export class OwnershipManager extends ComponentBase { if (value !== undefined) { existingEntry.value = value; } + // The origin (#484) is a (filePath, exportPath) pair: a registration from a DIFFERENT file + // replaces both together (a stale exportPath must never pair with a new file), while a + // same-file registration that carries no exportPath — a value-less namespace/marker touch — + // never downgrades one already recorded, exactly like `value` above. + if (filePath !== null && filePath !== existingEntry.filePath) { + existingEntry.exportPath = exportPath; + existingEntry.memberExportPaths = memberExportPaths; + } else { + if (exportPath !== null) existingEntry.exportPath = exportPath; + if (memberExportPaths !== null) existingEntry.memberExportPaths = memberExportPaths; + } if (filePath !== null) { existingEntry.filePath = filePath; } @@ -356,6 +717,8 @@ export class OwnershipManager extends ComponentBase { timestamp: Date.now(), value, filePath, + exportPath, + memberExportPaths, isMergeLoss }; @@ -726,7 +1089,7 @@ export class OwnershipManager extends ComponentBase { /** * Snapshot the entries moduleID currently owns, keyed by apiPath, for later restoration * @param {string} moduleID - Module identifier to snapshot. - * @returns {Map} + * @returns {Map} * One entry per apiPath the module currently owns, capturing exactly the fields a duplicate * registration can overwrite. * @public @@ -749,7 +1112,14 @@ export class OwnershipManager extends ComponentBase { // (reproducing it would require hand-corrupting the internal maps — a tautology, not a test). /* v8 ignore next */ if (!entry) continue; - snapshot.set(path, { value: entry.value, filePath: entry.filePath, source: entry.source, isMergeLoss: entry.isMergeLoss }); + snapshot.set(path, { + value: entry.value, + filePath: entry.filePath, + exportPath: entry.exportPath ?? null, + memberExportPaths: entry.memberExportPaths ?? null, + source: entry.source, + isMergeLoss: entry.isMergeLoss + }); } return snapshot; } @@ -759,7 +1129,7 @@ export class OwnershipManager extends ComponentBase { * overwrite without changing its position in the ownership stack * @param {string} moduleID - Module identifier. * @param {string} apiPath - API path whose entry to restore. - * @param {{value: *, filePath: (string|null), source: string, isMergeLoss: boolean}} snapshot - + * @param {{value: *, filePath: (string|null), exportPath?: (string[]|null), memberExportPaths?: (Object|null), source: string, isMergeLoss: boolean}} snapshot - * Prior field values, from {@link OwnershipManager#snapshotModuleEntries}. * @returns {void} * @public @@ -772,6 +1142,8 @@ export class OwnershipManager extends ComponentBase { if (!entry) return; entry.value = snapshot.value; entry.filePath = snapshot.filePath; + entry.exportPath = snapshot.exportPath ?? null; + entry.memberExportPaths = snapshot.memberExportPaths ?? null; entry.source = snapshot.source; entry.isMergeLoss = snapshot.isMergeLoss; } @@ -781,7 +1153,7 @@ export class OwnershipManager extends ComponentBase { * {@link OwnershipManager#snapshotModuleEntries}, for an internal candidate's own revert. * @param {string} apiPath - Full api path the candidate is about to (re-)contribute to. * @param {string} moduleID - Module identifier making the contribution. - * @returns {{value: *, filePath: (string|null), source: string, isMergeLoss: boolean}|undefined} + * @returns {{value: *, filePath: (string|null), exportPath: (string[]|null), memberExportPaths: (Object|null), source: string, isMergeLoss: boolean}|undefined} * The prior entry's snapshot, or `undefined` if none exists yet. * @public * @@ -808,7 +1180,14 @@ export class OwnershipManager extends ComponentBase { snapshotPathEntry(apiPath, moduleID) { const entry = this.pathToModule.get(apiPath)?.find((candidate) => candidate.moduleID === moduleID); if (!entry) return undefined; - return { value: entry.value, filePath: entry.filePath, source: entry.source, isMergeLoss: entry.isMergeLoss }; + return { + value: entry.value, + filePath: entry.filePath, + exportPath: entry.exportPath ?? null, + memberExportPaths: entry.memberExportPaths ?? null, + source: entry.source, + isMergeLoss: entry.isMergeLoss + }; } /** @@ -939,6 +1318,7 @@ export class OwnershipManager extends ComponentBase { this.pathToModule.clear(); this._unregisteredModules.clear(); this.moduleEndpoints.clear(); + this.exportIndex.clear(); } /** diff --git a/src/lib/handlers/unified-wrapper.mjs b/src/lib/handlers/unified-wrapper.mjs index bcce9458d..44a96b74d 100644 --- a/src/lib/handlers/unified-wrapper.mjs +++ b/src/lib/handlers/unified-wrapper.mjs @@ -909,6 +909,9 @@ export class UnifiedWrapper extends ComponentBase { * @param {boolean} [options.isCallable=false] - Whether the wrapper should be callable * @param {boolean} [options.materializeOnCreate=false] - Whether to materialize on creation * @param {string} [options.filePath=null] - File path of the module source + * @param {string[]|null} [options.exportPath] - Access path within `filePath`'s module namespace + * that produced `initialImpl` (#484). When omitted it is looked up from the ownership export index + * by `initialImpl`'s identity; `null` records "no module origin". * @param {string} [options.moduleID=null] - Module identifier * @param {string} [options.sourceFolder=null] - Source folder for metadata * @param {WeakSet|null} [options.__adoptVisited=null] - Internal: one-shot cycle-guard set @@ -943,6 +946,7 @@ export class UnifiedWrapper extends ComponentBase { isCallable, materializeOnCreate = false, filePath = null, + exportPath = undefined, moduleID = null, sourceFolder = null, // One-shot cycle-guard set threaded through the eager child-adoption recursion so a @@ -979,6 +983,22 @@ export class UnifiedWrapper extends ComponentBase { internal.isCallableLocked = isCallableLocked; internal.moduleID = moduleID; internal.filePath = filePath; + // Where initialImpl sits in filePath's module namespace (#484) — resolved before the clone below, + // against the value the module actually exported. Read by typegen through the ownership record. + // A primitive has no identity to look up; a module leaf built straight from one export (a folder + // namespace's per-export wrappers) is found by the export's name — its apiPath's last segment — + // confirmed by value. + const originIndex = slothlet.handlers?.ownership; + const isPrimitiveImpl = + initialImpl !== null && initialImpl !== undefined && typeof initialImpl !== "object" && typeof initialImpl !== "function"; + const ownKey = typeof apiPath === "string" ? apiPath.slice(apiPath.lastIndexOf(".") + 1) : null; + internal.exportPath = + exportPath !== undefined + ? exportPath + : (originIndex?.resolveExportPath(filePath, initialImpl) ?? + (isPrimitiveImpl && ownKey ? originIndex?.resolveExportPathByKey(filePath, ownKey, initialImpl) : null) ?? + null); + internal.memberExportPaths = originIndex?.resolveMemberExportPaths(filePath, initialImpl) ?? null; internal.sourceFolder = sourceFolder; // Cycle-guard set for this adopt traversal (see the constructor's __adoptVisited note, #330). internal.adoptVisited = __adoptVisited; @@ -1072,6 +1092,8 @@ export class UnifiedWrapper extends ComponentBase { source: "initial", moduleID, filePath, + exportPath: internal.exportPath, + memberExportPaths: internal.memberExportPaths, // sourceFolder is always provided by the caller; the config.dir fallback is never reached. /* v8 ignore next */ sourceFolder: sourceFolder || slothlet.config?.dir @@ -1333,6 +1355,15 @@ export class UnifiedWrapper extends ComponentBase { * @private */ _applyNewImpl(newImpl, forceReuseChildren = false) { + // Refresh the impl's module origin (#484) from the value as exported, before it is cloned. A new + // impl that is not a known export of this wrapper's file keeps the origin already recorded. + const originIndex = this.slothlet.handlers?.ownership; + const newExportPath = originIndex?.resolveExportPath(this.____slothletInternal.filePath, newImpl) ?? null; + if (newExportPath) { + this.____slothletInternal.exportPath = newExportPath; + this.____slothletInternal.memberExportPaths = + originIndex.resolveMemberExportPaths(this.____slothletInternal.filePath, newImpl) ?? null; + } // Clone to protect API cache from ___adoptImplChildren's delete operations. // See static _cloneImpl() for full rationale. this.____slothletInternal.impl = UnifiedWrapper._cloneImpl(newImpl); @@ -1415,6 +1446,8 @@ export class UnifiedWrapper extends ComponentBase { source: "hot-reload", moduleID: extractedModuleId, filePath: wrapperMetadata?.filePath, + exportPath: this.____slothletInternal.exportPath ?? null, + memberExportPaths: this.____slothletInternal.memberExportPaths ?? null, sourceFolder: wrapperMetadata?.sourceFolder }); } @@ -2317,6 +2350,8 @@ export class UnifiedWrapper extends ComponentBase { const descriptors = Object.getOwnPropertyDescriptors(childImpl); childImpl = Object.create(Object.getPrototypeOf(childImpl), descriptors); } + // Let an origin lookup against the clone resolve to the exported original (#484). + this.slothlet.handlers?.ownership?.noteClone(childImpl, value); } // Get parent wrapper's metadata to inherit filePath and moduleID @@ -2398,6 +2433,33 @@ export class UnifiedWrapper extends ComponentBase { const childSourceFolder = childExistingMetadata?.sourceFolder || parentMetadata?.sourceFolder || null; + // The child's module origin (#484). A child read from the parent's own file is located the same + // way the parent was: the parent's composed-key map first (a flattened namespace records where + // each key came from), then the child value's identity in that file's export index, then — for a + // member of an exported object — the parent's own exportPath extended by the key. A child from a + // different file (a folder namespace's per-file children) is located by identity in its own file. + // Last, for a child of a namespace that is not itself an export: the export of the child's file + // with that name, confirmed by value — the only route for a primitive such a namespace gathered + // from one of several files, which has no identity and no recorded per-key map. + const parentInternal = this.____slothletInternal; + const originIndex = this.slothlet.handlers?.ownership; + const sameFileAsParent = Boolean(childFilePath) && childFilePath === parentInternal.filePath; + const keyName = typeof key === "symbol" ? null : key; + let childExportPath = null; + if (childFilePath && keyName !== null) { + const recordedMember = sameFileAsParent ? parentInternal.memberExportPaths?.[keyName] : undefined; + if (recordedMember) { + childExportPath = [...recordedMember]; + } else if (sameFileAsParent && parentInternal.exportPath) { + childExportPath = originIndex?.resolveExportPath(childFilePath, value) ?? [...parentInternal.exportPath, keyName]; + } else { + childExportPath = + originIndex?.resolveExportPath(childFilePath, value) ?? + originIndex?.resolveExportPathByKey(childFilePath, keyName, value) ?? + null; + } + } + // Mark this value as on the descent path while its subtree is built, then unmark — so the // same reference reached again THROUGH this subtree (a cycle) bails, but a sibling reuse does // not. `finally` guarantees the unmark even if construction throws (#330). @@ -2417,6 +2479,7 @@ export class UnifiedWrapper extends ComponentBase { initialImpl: childImpl, isCallable: typeof childImpl === "function", filePath: childFilePath, + exportPath: childExportPath, moduleID: childModuleId, sourceFolder: childSourceFolder, // Thread the cycle-guard set so a cycle spanning this parent → descendant is detected (#330). diff --git a/src/lib/helpers/modes-utils.mjs b/src/lib/helpers/modes-utils.mjs index ea11c28dd..8f6aac078 100644 --- a/src/lib/helpers/modes-utils.mjs +++ b/src/lib/helpers/modes-utils.mjs @@ -55,11 +55,12 @@ export class ModesUtils extends ComponentBase { if (!value || typeof value !== "object") { return value; } - if (Array.isArray(value)) { - return value.slice(); - } - const descriptors = Object.getOwnPropertyDescriptors(value); - return Object.create(Object.getPrototypeOf(value), descriptors); + const clone = Array.isArray(value) + ? value.slice() + : Object.create(Object.getPrototypeOf(value), Object.getOwnPropertyDescriptors(value)); + // Let an origin lookup against the clone resolve to the exported original (#484). + this.slothlet?.handlers?.ownership?.noteClone(clone, value); + return clone; } /** diff --git a/src/lib/i18n/languages/de-de.json b/src/lib/i18n/languages/de-de.json index 929bd3f54..a31caba44 100644 --- a/src/lib/i18n/languages/de-de.json +++ b/src/lib/i18n/languages/de-de.json @@ -344,8 +344,6 @@ "HINT_TYPESCRIPT_ESBUILD_NOT_INSTALLED": "Installieren Sie es mit: npm install esbuild", "TYPESCRIPT_TSC_NOT_INSTALLED": "Der TypeScript {mode} Modus erfordert die Installation von 'typescript'.", "HINT_TYPESCRIPT_TSC_NOT_INSTALLED": "Installieren Sie es mit: npm install typescript", - "TYPESCRIPT_NOT_INSTALLED": "Die Typ-Generierung ({feature}) erfordert die Installation von 'typescript'.", - "HINT_TYPESCRIPT_NOT_INSTALLED": "Tipp: Für die Typgenerierung installieren Sie: npm install typescript", "TS_STRICT_REQUIRES_OUTPUT": "Der TypeScript Strict-Modus erfordert die Konfiguration von 'types.output' in Ihrer TypeScript-Konfiguration.", "HINT_TS_STRICT_REQUIRES_OUTPUT": "Fügen Sie 'types: { output: './types/api.d.ts', interfaceName: 'MeineAPI' }' zu Ihrer TypeScript-Konfiguration hinzu.", "TS_STRICT_REQUIRES_INTERFACE_NAME": "Der TypeScript Strict-Modus erfordert die Konfiguration von 'types.interfaceName' in Ihrer TypeScript-Konfiguration.", diff --git a/src/lib/i18n/languages/en-gb.json b/src/lib/i18n/languages/en-gb.json index 1c491eefe..9b41963b5 100644 --- a/src/lib/i18n/languages/en-gb.json +++ b/src/lib/i18n/languages/en-gb.json @@ -344,8 +344,6 @@ "HINT_TYPESCRIPT_ESBUILD_NOT_INSTALLED": "Install it with: npm install esbuild", "TYPESCRIPT_TSC_NOT_INSTALLED": "TypeScript {mode} mode requires 'typescript' to be installed.", "HINT_TYPESCRIPT_TSC_NOT_INSTALLED": "Install it with: npm install typescript", - "TYPESCRIPT_NOT_INSTALLED": "Type generation ({feature}) requires 'typescript' to be installed.", - "HINT_TYPESCRIPT_NOT_INSTALLED": "Tip: For type generation, install: npm install typescript", "TS_STRICT_REQUIRES_OUTPUT": "TypeScript strict mode requires 'types.output' to be configured in your typescript config.", "HINT_TS_STRICT_REQUIRES_OUTPUT": "Add 'types: { output: './types/api.d.ts', interfaceName: 'MyAPI' }' to your typescript configuration.", "TS_STRICT_REQUIRES_INTERFACE_NAME": "TypeScript strict mode requires 'types.interfaceName' to be configured in your typescript config.", diff --git a/src/lib/i18n/languages/en-us.json b/src/lib/i18n/languages/en-us.json index 3abf3df9c..df6823976 100644 --- a/src/lib/i18n/languages/en-us.json +++ b/src/lib/i18n/languages/en-us.json @@ -344,8 +344,6 @@ "HINT_TYPESCRIPT_ESBUILD_NOT_INSTALLED": "Install it with: npm install esbuild", "TYPESCRIPT_TSC_NOT_INSTALLED": "TypeScript {mode} mode requires 'typescript' to be installed.", "HINT_TYPESCRIPT_TSC_NOT_INSTALLED": "Install it with: npm install typescript", - "TYPESCRIPT_NOT_INSTALLED": "Type generation ({feature}) requires 'typescript' to be installed.", - "HINT_TYPESCRIPT_NOT_INSTALLED": "Install it with: npm install typescript", "TS_STRICT_REQUIRES_OUTPUT": "TypeScript strict mode requires 'types.output' to be configured in your typescript config.", "HINT_TS_STRICT_REQUIRES_OUTPUT": "Add 'types: { output: './types/api.d.ts', interfaceName: 'MyAPI' }' to your typescript configuration.", "TS_STRICT_REQUIRES_INTERFACE_NAME": "TypeScript strict mode requires 'types.interfaceName' to be configured in your typescript config.", diff --git a/src/lib/i18n/languages/es-es.json b/src/lib/i18n/languages/es-es.json index e66ad48e4..471f0f51a 100644 --- a/src/lib/i18n/languages/es-es.json +++ b/src/lib/i18n/languages/es-es.json @@ -344,8 +344,6 @@ "HINT_TYPESCRIPT_ESBUILD_NOT_INSTALLED": "Instálalo con: npm install esbuild", "TYPESCRIPT_TSC_NOT_INSTALLED": "El modo TypeScript {mode} requiere que 'typescript' esté instalado.", "HINT_TYPESCRIPT_TSC_NOT_INSTALLED": "Instálalo con: npm install typescript", - "TYPESCRIPT_NOT_INSTALLED": "La generación de tipos ({feature}) requiere que 'typescript' esté instalado.", - "HINT_TYPESCRIPT_NOT_INSTALLED": "Consejo: Para la generación de tipos, instala: npm install typescript", "TS_STRICT_REQUIRES_OUTPUT": "El modo estricto de TypeScript requiere que 'types.output' esté configurado en tu configuración de typescript.", "HINT_TS_STRICT_REQUIRES_OUTPUT": "Añade 'types: { output: './types/api.d.ts', interfaceName: 'MyAPI' }' a tu configuración de typescript.", "TS_STRICT_REQUIRES_INTERFACE_NAME": "El modo estricto de TypeScript requiere que 'types.interfaceName' esté configurado en tu configuración de typescript.", diff --git a/src/lib/i18n/languages/es-mx.json b/src/lib/i18n/languages/es-mx.json index ebc73f8e7..3ff6d35ed 100644 --- a/src/lib/i18n/languages/es-mx.json +++ b/src/lib/i18n/languages/es-mx.json @@ -344,8 +344,6 @@ "HINT_TYPESCRIPT_ESBUILD_NOT_INSTALLED": "Instálelo con: npm install esbuild", "TYPESCRIPT_TSC_NOT_INSTALLED": "El modo TypeScript {mode} requiere que 'typescript' esté instalado.", "HINT_TYPESCRIPT_TSC_NOT_INSTALLED": "Instálelo con: npm install typescript", - "TYPESCRIPT_NOT_INSTALLED": "La generación de tipos ({feature}) requiere que 'typescript' esté instalado.", - "HINT_TYPESCRIPT_NOT_INSTALLED": "Consejo: Para la generación de tipos, instale: npm install typescript", "TS_STRICT_REQUIRES_OUTPUT": "El modo estricto de TypeScript requiere que 'types.output' esté configurado en su configuración de typescript.", "HINT_TS_STRICT_REQUIRES_OUTPUT": "Añada 'types: { output: './types/api.d.ts', interfaceName: 'MyAPI' }' a su configuración de typescript.", "TS_STRICT_REQUIRES_INTERFACE_NAME": "El modo estricto de TypeScript requiere que 'types.interfaceName' esté configurado en su configuración de typescript.", diff --git a/src/lib/i18n/languages/fr-fr.json b/src/lib/i18n/languages/fr-fr.json index 7703c24a9..51c5390f2 100644 --- a/src/lib/i18n/languages/fr-fr.json +++ b/src/lib/i18n/languages/fr-fr.json @@ -344,8 +344,6 @@ "HINT_TYPESCRIPT_ESBUILD_NOT_INSTALLED": "Installez-le avec : npm install esbuild", "TYPESCRIPT_TSC_NOT_INSTALLED": "Le mode TypeScript {mode} nécessite l'installation de 'typescript'.", "HINT_TYPESCRIPT_TSC_NOT_INSTALLED": "Installez-le avec : npm install typescript", - "TYPESCRIPT_NOT_INSTALLED": "La génération de types ({feature}) nécessite l'installation de 'typescript'.", - "HINT_TYPESCRIPT_NOT_INSTALLED": "Conseil : pour la génération de types, installez : npm install typescript", "TS_STRICT_REQUIRES_OUTPUT": "Le mode strict de TypeScript nécessite la configuration de 'types.output' dans votre configuration typescript.", "HINT_TS_STRICT_REQUIRES_OUTPUT": "Ajoutez 'types: { output: './types/api.d.ts', interfaceName: 'MonAPI' }' à votre configuration typescript.", "TS_STRICT_REQUIRES_INTERFACE_NAME": "Le mode strict de TypeScript nécessite la configuration de 'types.interfaceName' dans votre configuration typescript.", diff --git a/src/lib/i18n/languages/hi-in.json b/src/lib/i18n/languages/hi-in.json index 9767e62cb..e18ff3626 100644 --- a/src/lib/i18n/languages/hi-in.json +++ b/src/lib/i18n/languages/hi-in.json @@ -344,8 +344,6 @@ "HINT_TYPESCRIPT_ESBUILD_NOT_INSTALLED": "इसे इसके साथ स्थापित करें: npm install esbuild", "TYPESCRIPT_TSC_NOT_INSTALLED": "TypeScript {mode} मोड के लिए 'typescript' स्थापित होना आवश्यक है।", "HINT_TYPESCRIPT_TSC_NOT_INSTALLED": "इसे इसके साथ स्थापित करें: npm install typescript", - "TYPESCRIPT_NOT_INSTALLED": "प्रकार पीढ़ी ({feature}) के लिए 'typescript' स्थापित होना आवश्यक है।", - "HINT_TYPESCRIPT_NOT_INSTALLED": "टिप: प्रकार पीढ़ी के लिए इंस्टॉल करें: npm install typescript", "TS_STRICT_REQUIRES_OUTPUT": "TypeScript strict मोड के लिए आपके typescript कॉन्फ़िगरेशन में 'types.output' कॉन्फ़िगर होना आवश्यक है।", "HINT_TS_STRICT_REQUIRES_OUTPUT": "अपने typescript कॉन्फ़िगरेशन में 'types: { output: './types/api.d.ts', interfaceName: 'MyAPI' }' जोड़ें।", "TS_STRICT_REQUIRES_INTERFACE_NAME": "TypeScript strict मोड के लिए आपके typescript कॉन्फ़िगरेशन में 'types.interfaceName' कॉन्फ़िगर होना आवश्यक है।", diff --git a/src/lib/i18n/languages/ja-jp.json b/src/lib/i18n/languages/ja-jp.json index 31180ee05..6c4ef3529 100644 --- a/src/lib/i18n/languages/ja-jp.json +++ b/src/lib/i18n/languages/ja-jp.json @@ -344,8 +344,6 @@ "HINT_TYPESCRIPT_ESBUILD_NOT_INSTALLED": "インストール方法: npm install esbuild", "TYPESCRIPT_TSC_NOT_INSTALLED": "TypeScript の {mode} モードには 'typescript' のインストールが必要です。", "HINT_TYPESCRIPT_TSC_NOT_INSTALLED": "インストール方法: npm install typescript", - "TYPESCRIPT_NOT_INSTALLED": "型生成 ({feature}) には 'typescript' のインストールが必要です。", - "HINT_TYPESCRIPT_NOT_INSTALLED": "ヒント: 型生成のために次をインストールしてください: npm install typescript", "TS_STRICT_REQUIRES_OUTPUT": "TypeScript の strict モードでは、構成の typescript セクションで 'types.output' を設定する必要があります。", "HINT_TS_STRICT_REQUIRES_OUTPUT": "typescript 構成に 'types: { output: './types/api.d.ts', interfaceName: 'MyAPI' }' を追加してください。", "TS_STRICT_REQUIRES_INTERFACE_NAME": "TypeScript の strict モードでは、構成の typescript セクションで 'types.interfaceName' を設定する必要があります。", diff --git a/src/lib/i18n/languages/ko-kr.json b/src/lib/i18n/languages/ko-kr.json index 2c03edd90..ae635bf6c 100644 --- a/src/lib/i18n/languages/ko-kr.json +++ b/src/lib/i18n/languages/ko-kr.json @@ -344,8 +344,6 @@ "HINT_TYPESCRIPT_ESBUILD_NOT_INSTALLED": "설치 명령: npm install esbuild", "TYPESCRIPT_TSC_NOT_INSTALLED": "TypeScript {mode} 모드에는 'typescript' 설치가 필요합니다.", "HINT_TYPESCRIPT_TSC_NOT_INSTALLED": "설치 명령: npm install typescript", - "TYPESCRIPT_NOT_INSTALLED": "타입 생성({feature})에는 'typescript' 설치가 필요합니다.", - "HINT_TYPESCRIPT_NOT_INSTALLED": "힌트: 타입 생성용으로 다음을 설치하십시오: npm install typescript", "TS_STRICT_REQUIRES_OUTPUT": "TypeScript 엄격(strict) 모드에는 typescript 설정에 'types.output' 설정이 필요합니다.", "HINT_TS_STRICT_REQUIRES_OUTPUT": "typescript 설정에 'types: { output: './types/api.d.ts', interfaceName: 'MyAPI' }'를 추가하십시오.", "TS_STRICT_REQUIRES_INTERFACE_NAME": "TypeScript 엄격 모드에는 typescript 설정에 'types.interfaceName' 설정이 필요합니다.", diff --git a/src/lib/i18n/languages/pt-br.json b/src/lib/i18n/languages/pt-br.json index 9589f562c..2f3debc41 100644 --- a/src/lib/i18n/languages/pt-br.json +++ b/src/lib/i18n/languages/pt-br.json @@ -344,8 +344,6 @@ "HINT_TYPESCRIPT_ESBUILD_NOT_INSTALLED": "Instale-o com: npm install esbuild", "TYPESCRIPT_TSC_NOT_INSTALLED": "O modo TypeScript {mode} requer que o 'typescript' esteja instalado.", "HINT_TYPESCRIPT_TSC_NOT_INSTALLED": "Instale-o com: npm install typescript", - "TYPESCRIPT_NOT_INSTALLED": "A geração de tipos ({feature}) requer que o 'typescript' esteja instalado.", - "HINT_TYPESCRIPT_NOT_INSTALLED": "Dica: para gerar tipos, instale: npm install typescript", "TS_STRICT_REQUIRES_OUTPUT": "O modo estrito do TypeScript requer que 'types.output' seja configurado em sua configuração typescript.", "HINT_TS_STRICT_REQUIRES_OUTPUT": "Adicione 'types: { output: './types/api.d.ts', interfaceName: 'MyAPI' }' à sua configuração typescript.", "TS_STRICT_REQUIRES_INTERFACE_NAME": "O modo estrito do TypeScript requer que 'types.interfaceName' seja configurado em sua configuração typescript.", diff --git a/src/lib/i18n/languages/ru-ru.json b/src/lib/i18n/languages/ru-ru.json index e61d7ecbb..6db47c2fd 100644 --- a/src/lib/i18n/languages/ru-ru.json +++ b/src/lib/i18n/languages/ru-ru.json @@ -344,8 +344,6 @@ "HINT_TYPESCRIPT_ESBUILD_NOT_INSTALLED": "Установите: npm install esbuild", "TYPESCRIPT_TSC_NOT_INSTALLED": "Для режима TypeScript {mode} требуется 'typescript'.", "HINT_TYPESCRIPT_TSC_NOT_INSTALLED": "Установите: npm install typescript", - "TYPESCRIPT_NOT_INSTALLED": "Генерация типов ({feature}) требует 'typescript'.", - "HINT_TYPESCRIPT_NOT_INSTALLED": "Подсказка: для генерации типов установите: npm install typescript", "TS_STRICT_REQUIRES_OUTPUT": "Режим TS strict требует настройки 'types.output' в конфигурации.", "HINT_TS_STRICT_REQUIRES_OUTPUT": "Добавьте 'types: { output: './types/api.d.ts', interfaceName: 'MyAPI' }'.", "TS_STRICT_REQUIRES_INTERFACE_NAME": "TS strict требует 'types.interfaceName'.", diff --git a/src/lib/i18n/languages/zh-cn.json b/src/lib/i18n/languages/zh-cn.json index 48190e840..aecb9d3e5 100644 --- a/src/lib/i18n/languages/zh-cn.json +++ b/src/lib/i18n/languages/zh-cn.json @@ -344,8 +344,6 @@ "HINT_TYPESCRIPT_ESBUILD_NOT_INSTALLED": "请安装:npm install esbuild", "TYPESCRIPT_TSC_NOT_INSTALLED": "TypeScript {mode} 模式需要安装 'typescript'。", "HINT_TYPESCRIPT_TSC_NOT_INSTALLED": "请安装:npm install typescript", - "TYPESCRIPT_NOT_INSTALLED": "类型生成({feature})需要安装 'typescript'。", - "HINT_TYPESCRIPT_NOT_INSTALLED": "提示:要生成类型,请安装:npm install typescript", "TS_STRICT_REQUIRES_OUTPUT": "TypeScript 严格模式要求在您的 TypeScript 配置中配置 'types.output'。", "HINT_TS_STRICT_REQUIRES_OUTPUT": "在您的 TypeScript 配置中添加 'types: { output: './types/api.d.ts', interfaceName: 'MyAPI' }'。", "TS_STRICT_REQUIRES_INTERFACE_NAME": "TypeScript 严格模式要求在您的 TypeScript 配置中配置 'types.interfaceName'。", diff --git a/src/lib/processors/flatten.mjs b/src/lib/processors/flatten.mjs index bdb8fda38..1f7f5ac04 100644 --- a/src/lib/processors/flatten.mjs +++ b/src/lib/processors/flatten.mjs @@ -243,7 +243,12 @@ export class Flatten extends ComponentBase { * @param {string|null} [options.collisionModeOverride=null] - Caller's per-call override (e.g. * `api.add({ forceOverwrite: true })`), preferred over `collisionContext`'s config default for * the function-default-vs-named-export merge decision below. - * @returns {{ moduleContent: object|Function }} Built module content ready for wrapping/assignment. + * @returns {{ moduleContent: object|Function, origins: {self: (string[]|null), members: Object} }} + * Built module content ready for wrapping/assignment, plus where it came from (#484): `self` is the + * content's own exportPath when it IS one export (`["default"]`, `["add"]`), `null` when it is a + * fresh object composed here; `members` maps each key this step placed onto the content to the + * exportPath it was read from. Keys the content already carried (a default object's own members) + * are not listed — they are located under `self`. * @public */ processModuleForAPI(options) { @@ -270,40 +275,54 @@ export class Flatten extends ComponentBase { (file && file.fullName && ["addapi.mjs", "addapi.cjs", "addapi.js", "addapi.ts"].includes(file.fullName.toLowerCase())); if (isAddapiFile && analysis.hasDefault && moduleKeys.length > 0) { const moduleContent = mod.default; + const members = {}; for (const key of moduleKeys) { moduleContent[key] = mod[key]; + members[key] = [key]; } - return { moduleContent }; + return { moduleContent, origins: { self: ["default"], members } }; } // Rule 7 (F02, F03) - C08: Auto-flattening (single named export matching module name) if (decision.useAutoFlattening) { - return { moduleContent: mod[moduleName] }; + return { moduleContent: mod[moduleName], origins: { self: [moduleName], members: {} } }; } // Rule 1 (F01) - C09: Flatten to root/category — merge all exports into one flat content object for caller to assign if (decision.flattenToRoot || decision.flattenToCategory) { if (mod.default && moduleKeys.length === 0) { - return { moduleContent: mod.default }; + return { moduleContent: mod.default, origins: { self: ["default"], members: {} } }; } if (mod.default && moduleKeys.length > 0) { - const moduleContent = typeof mod.default === "function" ? mod.default : { ...mod.default }; + const isFunctionDefault = typeof mod.default === "function"; + const moduleContent = isFunctionDefault ? mod.default : { ...mod.default }; + const members = {}; + if (!isFunctionDefault) { + // The spread copied the default object's own members onto a fresh object. + for (const key of Object.keys(mod.default)) members[key] = ["default", key]; + } for (const key of moduleKeys) { moduleContent[key] = mod[key]; + members[key] = [key]; } - return { moduleContent }; + return { moduleContent, origins: { self: isFunctionDefault ? ["default"] : null, members } }; } // Only named exports: expose each directly (caller merges to parent) const moduleContent = {}; + const members = {}; for (const key of moduleKeys) { moduleContent[key] = mod[key]; + members[key] = [key]; } - return { moduleContent }; + return { moduleContent, origins: { self: null, members } }; } // Rule 6 - C09a: Self-referential non-function — use the named export that is itself (or full mod) if (isSelfReferential) { - return { moduleContent: mod[moduleName] || mod }; + if (mod[moduleName]) return { moduleContent: mod[moduleName], origins: { self: [moduleName], members: {} } }; + const members = {}; + for (const key of Object.keys(mod)) members[key] = [key]; + return { moduleContent: mod, origins: { self: null, members } }; } // Hybrid pattern: default + named exports @@ -311,6 +330,7 @@ export class Flatten extends ComponentBase { if (typeof mod.default === "function") { // Default is a function: attach named exports as properties (e.g. logger(), logger.info()) const moduleContent = this.slothlet.helpers.modesUtils.ensureNamedExportFunction(mod.default, propertyName); + const members = {}; const collisionConfig = this.slothlet.config.api?.collision || this.slothlet.config.collision; // Per-call override (e.g. api.add({ forceOverwrite: true })) takes priority over the // config default, matching every other collision decision in this same build @@ -344,13 +364,15 @@ export class Flatten extends ComponentBase { // collisionMode "replace" / "merge-replace" — fall through to assignment } moduleContent[key] = mod[key]; + members[key] = [key]; } - return { moduleContent }; + return { moduleContent, origins: { self: ["default"], members } }; } if (typeof mod.default === "object" && mod.default !== null) { // Default is an object: use it directly and merge named exports. Same-name conflicts // are resolved by collisionMode, consistent with the function-default branch above (#421). const moduleContent = mod.default; + const members = {}; const collisionConfig = this.slothlet.config.api?.collision || this.slothlet.config.collision; const collisionMode = collisionModeOverride || (collisionContext === "initial" ? collisionConfig.initial : collisionConfig.api); for (const key of moduleKeys) { @@ -381,28 +403,36 @@ export class Flatten extends ComponentBase { // collisionMode "replace" / "merge-replace" — fall through to assignment } moduleContent[key] = mod[key]; + members[key] = [key]; } - return { moduleContent }; + return { moduleContent, origins: { self: ["default"], members } }; } // Default is a primitive: wrap in a namespace object const moduleContent = { default: mod.default }; + const members = { default: ["default"] }; for (const key of moduleKeys) { moduleContent[key] = mod[key]; + members[key] = [key]; } - return { moduleContent }; + return { moduleContent, origins: { self: null, members } }; } // Rule 1 (F01), Rule 2 - C09b: Traditional namespace preservation — only default export, use it directly if (mod.default && moduleKeys.length === 0) { - return { moduleContent: this.slothlet.helpers.modesUtils.ensureNamedExportFunction(mod.default, propertyName) }; + return { + moduleContent: this.slothlet.helpers.modesUtils.ensureNamedExportFunction(mod.default, propertyName), + origins: { self: ["default"], members: {} } + }; } // Fallback: named-only exports (no default — all truthy-default paths return above) const moduleContent = {}; + const members = {}; for (const key of moduleKeys) { moduleContent[key] = mod[key]; + members[key] = [key]; } - return { moduleContent }; + return { moduleContent, origins: { self: null, members } }; } /** diff --git a/src/lib/processors/type-generator.mjs b/src/lib/processors/type-generator.mjs index 675a9bd65..a9c0598de 100644 --- a/src/lib/processors/type-generator.mjs +++ b/src/lib/processors/type-generator.mjs @@ -20,43 +20,45 @@ import fs from "fs"; import path from "path"; import { SlothletError } from "@cldmv/slothlet/errors"; +import { resolveWrapper } from "#handlers/unified-wrapper"; -let typescriptInstance = null; +/** + * Root keys the framework owns: typed by `SlothletAPI` (extended below), never by the generated interface. + * @type {Set} + * @private + */ +const FRAMEWORK_ROOT_KEYS = new Set(["slothlet", "shutdown", "destroy"]); /** - * Lazy-load TypeScript compiler to avoid requiring installation when not using type generation - * @returns {Promise} typescript module - * @throws {SlothletError} TYPESCRIPT_NOT_INSTALLED if typescript is not installed + * Identifier test for an unquoted interface member name. + * @type {RegExp} * @private */ -async function getTypeScript() { - if (!typescriptInstance) { - try { - typescriptInstance = await import("typescript"); - // unreachable via tests: typescript is a devDependency always present during testing. - // The catch only fires in end-user environments where typescript is not installed. - /* v8 ignore start */ - } catch (error) { - throw new SlothletError("TYPESCRIPT_NOT_INSTALLED", { feature: "type-generation" }, error); - } - /* v8 ignore stop */ - } - return typescriptInstance; -} +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; /** * Generate TypeScript declaration file for a Slothlet API - * @param {object} api - The loaded Slothlet API + * @param {object} api - The loaded Slothlet API (a live, composed instance) * @param {object} options - Generation options * @param {string} options.output - Output file path for .d.ts * @param {string} options.interfaceName - Name of the interface to generate - * @param {boolean} [options.includeDocumentation=true] - Include JSDoc comments + * @param {boolean} [options.includeDocumentation=true] - Include the per-member comments that explain an + * `unknown` member (a member with no module origin). Accepted for compatibility; the explanation is + * always emitted, since an unexplained `unknown` is a worse surprise than one extra comment line. + * @param {boolean} [options.augmentRuntime=true] - Extend `SlothletSelf` from `@cldmv/slothlet/runtime` + * with the generated interface, so an imported `self` is typed as the api. Turn it off when several + * generated interfaces live in one TypeScript program and only one of them should type `self`. * @returns {Promise<{output: string, filePath: string}>} Generated declaration and output path * @public + * + * @description + * Every member references the export slothlet actually placed there — `typeof import("")["k"]` — + * read from the ownership records' module origin (#484), so the declaration carries each leaf's own + * type (JSDoc, TypeScript, CommonJS alike) instead of a signature guessed from its file's syntax. A + * namespace becomes a nested object of its members; a callable namespace intersects its function's + * type with the members merged onto it; a member with no module origin is `unknown`, with a comment. */ export async function generateTypes(api, options) { - const ts = await getTypeScript(); - if (!options.output) { throw new SlothletError("INVALID_CONFIG", { option: "types.output", @@ -77,30 +79,19 @@ export async function generateTypes(api, options) { }); } - // Traverse the API and extract structure - const nodes = traverseAPI(api); - - // Extract type information from the backing source files. A single TypeScript Program over every - // file gives a real type checker, so JSDoc `@param {T}` / `@returns {T}` — including `@param - // {object} o` with dotted `o.prop` shapes, optional params, unions, and generics — resolve to - // proper, valid TypeScript the way the type checker reads JSDoc annotations, rather than the - // raw-text guesswork a bare `createSourceFile` parse produced. (Types are read, not checked — - // the Program runs with `checkJs: false`; it reflects JSDoc, it does not type-check the sources.) (#213) - const filePaths = [...new Set(nodes.map((node) => node.metadata?.filePath).filter(Boolean))]; - const { byFile: exportsByFile, localTypes } = extractTypeInfo(filePaths, ts); - for (const node of nodes) { - if (node.metadata?.filePath) { - node.typeInfo = exportsByFile.get(node.metadata.filePath); - } - } - - // Generate declaration content - const declaration = generateDeclaration(nodes, options, localTypes); - - // Write to file const outputPath = path.resolve(options.output); const outputDir = path.dirname(outputPath); + const context = { + ownership: findOwnership(api), + routineNames: findRoutineNames(api), + outputDir + }; + + // Describe the api, then render it. + const members = describeMembers(api, "", context, new Set()); + const declaration = generateDeclaration(members, options); + // Ensure the output directory exists. mkdir with recursive:true is idempotent // and never throws when the directory already exists, so no existsSync pre-check // is needed (avoids a TOCTOU window / CWE-367). @@ -115,211 +106,223 @@ export async function generateTypes(api, options) { } /** - * Traverse API structure and collect nodes - * @param {object} api - The API to traverse - * @param {string[]} [currentPath=[]] - Current path in traversal - * @param {Set} [visited=new Set()] - Visited objects to prevent cycles - * @returns {object[]} Array of API nodes + * Find the ownership records behind a composed api by resolving any of its wrapper-backed members. + * @param {*} api - The composed api. + * @returns {object|null} The instance's `OwnershipManager`, or `null` for a value slothlet did not compose. * @private */ -function traverseAPI(api, currentPath = [], visited = new Set()) { - const nodes = []; +function findOwnership(api) { + const inner = findInstance(api); + return inner?.handlers?.ownership ?? null; +} - if (!api || typeof api !== "object") { - return nodes; +/** + * Find the Slothlet instance behind a composed api (through the first wrapper-backed member). + * @param {*} api - The composed api. + * @returns {object|null} The instance, or `null`. + * @private + */ +function findInstance(api) { + if (!api || (typeof api !== "object" && typeof api !== "function")) return null; + for (const key of Object.keys(api)) { + if (FRAMEWORK_ROOT_KEYS.has(key)) continue; + const wrapper = resolveWrapper(api[key]); + if (wrapper?.slothlet) return wrapper.slothlet; } + return null; +} - // Prevent infinite loops from circular references - if (visited.has(api)) { - return nodes; - } - visited.add(api); +/** + * The instance's configured routine names — their root slots hold the framework's routine cascade. + * @param {*} api - The composed api. + * @returns {Set} Routine names (empty when unknown). + * @private + */ +function findRoutineNames(api) { + const routines = findInstance(api)?.config?.routines; + return new Set(Array.isArray(routines) ? routines.map((routine) => routine?.name).filter(Boolean) : []); +} - for (const [key, value] of Object.entries(api)) { - // Skip internal properties and Slothlet system properties - if (key.startsWith("_") || key.startsWith("__") || key === "slothlet" || key === "shutdown" || key === "destroy") { - continue; +/** + * Describe the members of one api level. + * @param {*} value - The level (the root api, or a namespace). + * @param {string} apiPath - Dotted path of `value` ("" at the root). + * @param {object} context - Shared generation state (`ownership`, `routineNames`, `outputDir`). + * @param {Set<*>} ancestors - Values on the current descent path (cycle guard). + * @returns {Array<{key: string, type: string, comment: (string|null)}>} One entry per member. + * @private + */ +function describeMembers(value, apiPath, context, ancestors) { + if (!value || (typeof value !== "object" && typeof value !== "function")) { + return []; + } + if (ancestors.has(value)) return []; + ancestors.add(value); + try { + const members = []; + for (const key of Object.keys(value)) { + // Skip internal properties and the framework's own root surface (typed by SlothletAPI). + if (key.startsWith("_") || (apiPath === "" && FRAMEWORK_ROOT_KEYS.has(key))) { + continue; + } + const childPath = apiPath ? `${apiPath}.${key}` : key; + members.push({ key, ...describeNode(value[key], childPath, context, ancestors) }); } + return members; + } finally { + ancestors.delete(value); + } +} - const nodePath = [...currentPath, key]; - const metadata = value?.__metadata; - - if (typeof value === "function") { - nodes.push({ - type: "function", - path: nodePath, - value, - metadata - }); - } else if (typeof value === "object" && value !== null) { - nodes.push({ - type: "object", - path: nodePath, - value, - metadata - }); - - // Recursively traverse nested objects - const childNodes = traverseAPI(value, nodePath, visited); - nodes.push(...childNodes); +/** + * Describe one api node as a TypeScript type expression. + * @param {*} value - The node's live value. + * @param {string} apiPath - Dotted api path of the node. + * @param {object} context - Shared generation state. + * @param {Set<*>} ancestors - Cycle guard. + * @returns {{type: string, comment: (string|null)}} The type, plus a comment when it needs one. + * @private + */ +function describeNode(value, apiPath, context, ancestors) { + const origin = context.ownership?.getOrigin(apiPath) ?? null; + const base = originType(origin, context); + const isComposite = (typeof value === "object" && value !== null) || typeof value === "function"; + + if (!base) { + // The root slot of a configured routine holds the framework's routine cascade: it forwards its + // arguments to every contribution and resolves to their result(s). + if (apiPath.indexOf(".") === -1 && typeof value === "function" && context.routineNames.has(apiPath)) { + return { type: "(...args: unknown[]) => Promise", comment: `Routine cascade: runs every \`${apiPath}\` contribution.` }; } + const children = isComposite ? describeMembers(value, apiPath, context, ancestors) : []; + if (children.length > 0) { + const comment = typeof value === "function" ? "Callable with no module origin: only its members are typed." : null; + return { type: renderObject(children, 1), comment }; + } + return { type: "unknown", comment: noOriginReason(origin) }; } - return nodes; + // A member the base type already describes (it is that export's own property) is not repeated; + // the rest — members merged onto an export from elsewhere — are added to it. + const children = isComposite ? describeMembers(value, apiPath, context, ancestors) : []; + const extra = children.filter((child) => !isCoveredBy(origin, context.ownership.getOrigin(`${apiPath}.${child.key}`))); + if (extra.length === 0) { + return { type: base, comment: null }; + } + if (typeof value === "function") { + return { type: `${base} & ${renderObject(extra, 1)}`, comment: null }; + } + // A plain object export whose members were partly replaced or extended: drop the replaced keys from + // the export's type so the placed values win, rather than intersecting two conflicting types. + const omitted = extra.map((child) => JSON.stringify(child.key)).join(" | "); + return { type: `Omit<${base}, ${omitted}> & ${renderObject(extra, 1)}`, comment: null }; } /** - * Extract exported-function type info for a set of source files using a single TypeScript Program, so - * the type checker resolves JSDoc types the way `tsc` reads JSDoc annotations. Returns a Map of - * `filePath → { exports: [{ name, type, signature }] }`; a file that cannot be parsed contributes an - * empty export list rather than failing the whole run. - * - * @param {string[]} filePaths - Absolute source-file paths backing the API nodes. - * @param {object} ts - TypeScript compiler instance. - * @returns {{byFile: Map, localTypes: Map}} Per-file - * extracted export signatures, plus the referenced local named-type declarations to emit. + * Whether a child's origin is exactly the parent export's own property of the same name. + * @param {object} parentOrigin - The parent node's origin (with an exportPath). + * @param {object|null} childOrigin - The child's origin. + * @returns {boolean} True when the parent's type already describes the child. * @private */ -function extractTypeInfo(filePaths, ts) { - const byFile = new Map(); - const program = ts.createProgram(filePaths, { - allowJs: true, - checkJs: false, - // Enable declaration emit so TypeScript's OWN emitter can produce the referenced local type - // declarations below — the faithful source for their exact ambient form (`declare enum`, filled - // enum initializers, kept/stripped `export`) that a raw source-text paste gets wrong. Emit is - // captured in memory via a custom writer; nothing is written to disk. - declaration: true, - emitDeclarationOnly: true, - skipLibCheck: true, - target: ts.ScriptTarget.Latest, - module: ts.ModuleKind.ESNext, - moduleResolution: ts.ModuleResolutionKind.Bundler - }); - const checker = program.getTypeChecker(); - const isExported = (node) => node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword); - - // Catalog every local named type (interface / type alias / enum) by reading TypeScript's own - // declaration-emit output — so each carries exactly the form `tsc` would write. A `.ts` leaf can - // reference such a type in a signature, and the checker prints it by name; that name must be - // emitted into the generated declaration or it resolves to nothing and the .d.ts fails to compile. - // Keyed by name; a same-name type from a later file overwrites (a rare cross-file clash, documented). - const candidates = new Map(); - program.emit( - undefined, - (____fileName, text) => { - const dts = ts.createSourceFile("emitted.d.ts", text, ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); - for (const stmt of dts.statements) { - // Interface / type-alias / enum declarations always carry a name. - if (ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isEnumDeclaration(stmt)) { - candidates.set(stmt.name.text, stmt.getText(dts)); - } - } - }, - undefined, - true - ); +function isCoveredBy(parentOrigin, childOrigin) { + if (!childOrigin?.exportPath || childOrigin.filePath !== parentOrigin.filePath) return false; + const parentPath = parentOrigin.exportPath; + const childPath = childOrigin.exportPath; + return childPath.length === parentPath.length + 1 && parentPath.every((segment, index) => segment === childPath[index]); +} - for (const filePath of filePaths) { - const exportsForFile = []; - try { - const visit = (node) => { - // export function foo(...) { ... } - if (ts.isFunctionDeclaration(node) && node.name && isExported(node)) { - exportsForFile.push({ name: node.name.text, type: "function", signature: signatureText(node, checker, ts) }); - // export const foo = (...) => ... | function (...) { ... } - } else if (ts.isVariableStatement(node) && isExported(node)) { - for (const decl of node.declarationList.declarations) { - const init = decl.initializer; - if (ts.isIdentifier(decl.name) && init && (ts.isArrowFunction(init) || ts.isFunctionExpression(init))) { - exportsForFile.push({ name: decl.name.text, type: "function", signature: signatureText(init, checker, ts) }); - } - } - } - ts.forEachChild(node, visit); - }; - // getSourceFile can return undefined (path unreadable or not included as a source file). - // Skip it explicitly so a missing file is a no-op, not a swallowed visit(undefined) throw. - const sourceFile = program.getSourceFile(filePath); - if (sourceFile) { - visit(sourceFile); - } - } catch (____error) { - // Unparseable / unresolvable file — contribute no signatures rather than fail the run. - } - byFile.set(filePath, { exports: exportsForFile }); +/** + * Explain why a node is typed `unknown`. + * @param {object|null} origin - The node's ownership origin, if any. + * @returns {string} The explanation. + * @private + */ +function noOriginReason(origin) { + if (!origin) { + return "No module origin: slothlet holds no ownership record for this member."; } - - const localTypes = collectReferencedLocalTypes(candidates, byFile); - return { byFile, localTypes }; + return "No module origin: not an export of a module file (a runtime assignment, in-memory api.add() exports, or a slothlet-created value)."; } /** - * Compute the transitive set of local named-type declarations the generated signatures reference, so - * they can be emitted alongside the interface and the declaration compiles standalone. A type is - * referenced when its name appears (whole-word) in any signature, or in the declaration text of an - * already-referenced type. Matching is purely lexical over the finite set of local type names — - * never a type-graph walk, which recursive generics could instantiate without bound — so the closure - * always terminates; over-inclusion would only add an unused-but-valid declaration. - * - * @param {Map} candidates - Every local named type: name → its declaration text. - * @param {Map} byFile - Per-file extracted signatures. - * @returns {Map} Referenced local types (name → declaration text), dependency-closed. + * The `typeof import(...)` type of a node's originating export. + * @param {object|null} origin - Ownership origin (`filePath` + `exportPath`). + * @param {object} context - Shared generation state (`outputDir`, `ownership`). + * @returns {string|null} The type expression, or `null` when the node has no module origin. * @private */ -function collectReferencedLocalTypes(candidates, byFile) { - const referenced = new Map(); - if (candidates.size === 0) return referenced; - - const wholeWord = (name) => new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`); - const signatureBlob = []; - for (const { exports } of byFile.values()) { - for (const e of exports) signatureBlob.push(e.signature); +function originType(origin, context) { + if (!origin?.exportPath || !origin.filePath) return null; + let segments = origin.exportPath; + // A CommonJS module's exports ARE `module.exports`; the loader exposes that object as `default`, but + // TypeScript types `import("x.cjs")` itself as `module.exports` (its `["default"]` is not the same + // type under NodeNext or Bundler), so the leading `default` segment is dropped. + if (segments[0] === "default" && context.ownership.isCommonJSModule(origin.filePath)) { + segments = segments.slice(1); } - const haystack = signatureBlob.join("\n"); + const access = segments.map((segment) => `[${JSON.stringify(segment)}]`).join(""); + return `typeof import(${JSON.stringify(importSpecifier(origin.filePath, context.outputDir))})${access}`; +} - const consider = (name) => { - if (referenced.has(name)) return; - const text = candidates.get(name); - referenced.set(name, text); - // Pull in any other local type this declaration itself references (transitive closure). - for (const other of candidates.keys()) { - if (wholeWord(other).test(text)) consider(other); - } - }; +/** + * A relative import specifier from the generated file's directory to a leaf file, with the extension a + * TypeScript import names at runtime (`.ts`→`.js`, `.mts`→`.mjs`, `.cts`→`.cjs`), so NodeNext and + * Bundler resolution both find the source. + * @param {string} filePath - Absolute leaf path. + * @param {string} outputDir - Absolute directory of the generated file. + * @returns {string} POSIX relative specifier starting with `./` or `../`. + * @private + */ +function importSpecifier(filePath, outputDir) { + let relative = path.relative(outputDir, filePath).split(path.sep).join("/"); + if (!relative.startsWith(".")) relative = `./${relative}`; + return relative.replace(/\.(m|c)?ts$/, (____match, kind) => `.${kind ?? ""}js`).replace(/\.tsx$/, ".jsx"); +} - for (const name of candidates.keys()) { - if (wholeWord(name).test(haystack)) consider(name); - } - return referenced; +/** + * Render members as an object type literal. + * @param {Array<{key: string, type: string, comment: (string|null)}>} members - Members to render. + * @param {number} indent - Indentation depth of the members. + * @returns {string} The object type (`{ ... }`). + * @private + */ +function renderObject(members, indent) { + const lines = ["{"]; + renderMembers(members, indent, lines); + lines.push(`${"\t".repeat(indent - 1)}}`); + return lines.join("\n"); } /** - * Render a function/arrow/function-expression node's call signature as TypeScript text - * (`(params): returnType`) via the type checker, so JSDoc-declared types are reflected exactly as - * `tsc` resolves them (object shapes from dotted `@param` tags, optional params, unions, generics). - * - * @param {object} fnNode - A function declaration, arrow function, or function expression node. - * @param {object} checker - The Program's type checker. - * @param {object} ts - TypeScript compiler instance. - * @returns {string} The call signature, e.g. `(name: string, times: number): string`. + * Append rendered members to `lines`, nesting object types one level deeper. + * @param {Array<{key: string, type: string, comment: (string|null)}>} members - Members to render. + * @param {number} indent - Indentation depth. + * @param {string[]} lines - Output lines. + * @returns {void} * @private */ -function signatureText(fnNode, checker, ts) { - const signature = checker.getSignatureFromDeclaration(fnNode); - return checker.signatureToString(signature, fnNode, ts.TypeFormatFlags.NoTruncation); +function renderMembers(members, indent, lines) { + const pad = "\t".repeat(indent); + for (const { key, type, comment } of members) { + if (comment) lines.push(`${pad}/** ${comment} */`); + const name = IDENTIFIER.test(key) ? key : JSON.stringify(key); + // Nested object literals were rendered at depth 1; re-indent them to this member's depth. + const reindented = type.split("\n").join(`\n${pad}`); + lines.push(`${pad}${name}: ${reindented};`); + } } /** * Generate TypeScript declaration file content - * @param {object[]} nodes - API nodes + * @param {Array<{key: string, type: string, comment: (string|null)}>} members - Root members. * @param {object} options - Generation options - * @param {Map} localTypes - Referenced local named-type declarations to emit. * @returns {string} Declaration file content * @private */ -function generateDeclaration(nodes, options, localTypes) { +function generateDeclaration(members, options) { const interfaceName = options.interfaceName; + const augment = options.augmentRuntime !== false; const lines = []; lines.push("/**"); @@ -328,96 +331,31 @@ function generateDeclaration(nodes, options, localTypes) { lines.push(" */"); lines.push(""); - // Emit the local named types the signatures reference (from `.ts` leaves) so the interface's - // by-name references resolve and the declaration compiles standalone. Type/interface/enum - // declarations hoist, so emitting them ahead of the interface is order-independent. - if (localTypes.size > 0) { - for (const text of localTypes.values()) { - lines.push(text); - } + if (augment) { + lines.push(`import type { SlothletAPI } from "@cldmv/slothlet";`); lines.push(""); } - // Build nested structure - const structure = {}; - - for (const node of nodes) { - if (node.type === "function") { - // Match by the function's own name (last path segment) so that when multiple - // functions share the same source file, each gets its own signature rather - // than an unrelated export's signature. - const fnName = node.path[node.path.length - 1]; - const exportInfo = node.typeInfo?.exports?.find((e) => e.name === fnName); - // If no matching export found (renamed key, no filePath, or parse failure) - // use a safe generic signature rather than borrowing a wrong one. - const signature = exportInfo?.signature ?? "(...args: any[]): any"; - setNestedProperty(structure, node.path, { type: "function", signature }); - } - // Skip intermediate "object" nodes - they're just containers for nested properties - // The structure will be built implicitly as we set nested function properties - } - - // Generate interface lines.push(`export interface ${interfaceName} {`); - generateInterfaceContent(structure, lines, 1); + renderMembers(members, 1, lines); lines.push("}"); lines.push(""); - // Generate self declaration for TypeScript files to use - lines.push(`declare const self: ${interfaceName};`); - lines.push(""); - - return lines.join("\n"); -} - -/** - * Set nested property in structure - * @param {object} obj - Object to set property in - * @param {string[]} path - Property path - * @param {*} value - Value to set - * @private - */ -function setNestedProperty(obj, path, value) { - let current = obj; - - for (let i = 0; i < path.length - 1; i++) { - const key = path[i]; - if (!current[key]) { - current[key] = {}; - } - current = current[key]; + // Type `self` for every leaf that imports it (#484). `self` is declared as the empty + // `SlothletSelf` class in `@cldmv/slothlet/runtime`; an interface of the same name merges into + // that class, so extending it here gives `self.*` the api's full shape in .ts, .mts and + // JSDoc-checked .mjs/.cjs leaves alike. (A `declare const self` in this file would be scoped to + // this module — the file has top-level exports — and could not retype an imported binding.) + // `self` is the same bound api `slothlet()` returns, so it also carries the framework surface + // (`self.slothlet.*`, `self.shutdown()`), which `SlothletAPI` describes. The two heritage clauses + // merge into one `SlothletSelf` that extends both. + if (augment) { + lines.push(`declare module "@cldmv/slothlet/runtime" {`); + lines.push(`\tinterface SlothletSelf extends ${interfaceName} {}`); + lines.push(`\tinterface SlothletSelf extends SlothletAPI {}`); + lines.push("}"); + lines.push(""); } - const lastKey = path[path.length - 1]; - current[lastKey] = value; -} - -/** - * Generate interface content recursively - * @param {object} structure - Nested structure - * @param {string[]} lines - Output lines - * @param {number} indent - Indentation level - * @private - */ -function generateInterfaceContent(structure, lines, indent) { - const indentation = "\t".repeat(indent); - - for (const [key, value] of Object.entries(structure)) { - // The else-if and else branches are never reached in tests (transformer only emits "function" entries); IF FALSE unreachable. - /* v8 ignore next */ - if (value.type === "function") { - lines.push(`${indentation}${key}${value.signature};`); - // The transformer only emits "function" entries and nested objects; - // the else-if false branch (primitive/null value) and else body are never reached in tests. - /* v8 ignore start */ - } else if (typeof value === "object" && value !== null) { - // Intermediate container object — recurse into it - lines.push(`${indentation}${key}: {`); - generateInterfaceContent(value, lines, indent + 1); - lines.push(`${indentation}};`); - } else { - // Primitive value type — never produced by transformer; defensive guard only. - } - /* v8 ignore stop */ - } + return lines.join("\n"); } diff --git a/src/lib/runtime/runtime.mjs b/src/lib/runtime/runtime.mjs index aef2769bf..66f88f5f2 100644 --- a/src/lib/runtime/runtime.mjs +++ b/src/lib/runtime/runtime.mjs @@ -25,7 +25,7 @@ * * | Export | Type | Description | * | --- | --- | --- | - * | `self` | `object` | Live reference to the full Slothlet API proxy. Use to call sibling modules without import cycles. | + * | `self` | `SlothletSelf` | Live reference to the full Slothlet API proxy. Use to call sibling modules without import cycles. Typed by extending `SlothletSelf` (see `slothlet typegen`). | * | `context` | `object` | The current ambient context object. Seeded at startup via `config.context` and persists across calls. `api.slothlet.context.run()` / `.scope()` can override it for the duration of a single call. Readable and writable. | * | `instanceID` | `string` | Unique identifier of the active Slothlet instance. | * @@ -64,12 +64,33 @@ function getCurrentRuntime() { return asyncRuntimeModule; } +/** + * Type anchor for {@link self}. It has no runtime behavior and is never instantiated. + * + * `self` is typed as `SlothletSelf`, and TypeScript merges an interface of the same name into a + * class, so a project types `self` for its whole api by extending this class from a module + * augmentation. `slothlet typegen` emits exactly that: + * ```ts + * declare module "@cldmv/slothlet/runtime" { + * interface SlothletSelf extends MyApi {} + * } + * ``` + * + * @memberof module:@cldmv/slothlet/runtime + * @example + * // api.d.ts (generated by `slothlet typegen -n MyApi`) extends SlothletSelf, so in any leaf: + * import { self } from "@cldmv/slothlet/runtime"; + * await self.math.add(1, 2); // typed from MyApi + */ +export class SlothletSelf {} + /** * Live binding to the current API instance. Resolves to the running Slothlet proxy, * giving API modules access to all other API methods without import cycles. + * Typed as {@link SlothletSelf}, which generated api types extend. * * @memberof module:@cldmv/slothlet/runtime - * @type {object} + * @type {SlothletSelf} * @example * import { self } from "@cldmv/slothlet/runtime"; * // Inside an API function: diff --git a/src/lib/typegen/typegen.mjs b/src/lib/typegen/typegen.mjs index 62af3924f..d40020054 100644 --- a/src/lib/typegen/typegen.mjs +++ b/src/lib/typegen/typegen.mjs @@ -50,6 +50,9 @@ import { SlothletError } from "@cldmv/slothlet/errors"; * @param {string} options.interfaceName - Name of the generated TypeScript interface (e.g. `"MyApi"`). * @param {boolean|string|object} [options.typescript] - Override TypeScript loader config. Same union accepted by `slothlet({ typescript })`: pass `true` (default mode), `"fast"` / `"strict"`, or an object like `{ mode: "fast" }`. Defaults to `{ mode: "fast" }` when omitted. * @param {boolean} [options.includeDocumentation=true] - Include JSDoc comments in the generated declaration. + * @param {boolean} [options.augmentRuntime=true] - Extend `SlothletSelf` from `@cldmv/slothlet/runtime` with the + * generated interface, so leaves that import `self` get the api's types. Set `false` when another generated + * declaration in the same TypeScript program should type `self` instead. * @returns {Promise<{filePath: string, content: string}>} Absolute path written and the declaration content. * @throws {SlothletError} `INVALID_CONFIG` when `dir`, `output`, or `interfaceName` is missing or not a string. * @public @@ -85,6 +88,20 @@ export async function generateTypes(options = {}) { assertOption(options.dir, "dir"); assertOption(options.output, "output"); assertOption(options.interfaceName, "interfaceName"); + if (options.augmentRuntime !== undefined && typeof options.augmentRuntime !== "boolean") { + throw new SlothletError( + "INVALID_CONFIG", + { + option: "typegen.augmentRuntime", + expected: "boolean", + value: options.augmentRuntime, + hint: "Pass augmentRuntime: false to leave `self` untyped by this declaration, or omit it.", + validationError: true + }, + null, + { validationError: true } + ); + } const api = await slothlet({ base: path.resolve(options.dir), @@ -96,7 +113,8 @@ export async function generateTypes(options = {}) { const result = await writeDeclarationFile(api, { output: path.resolve(options.output), interfaceName: options.interfaceName, - includeDocumentation: options.includeDocumentation ?? true + includeDocumentation: options.includeDocumentation ?? true, + augmentRuntime: options.augmentRuntime ?? true }); return { filePath: result.filePath, content: result.output }; } finally { diff --git a/src/slothlet.mjs b/src/slothlet.mjs index 14e357fed..83e53f0b6 100644 --- a/src/slothlet.mjs +++ b/src/slothlet.mjs @@ -461,6 +461,9 @@ class Slothlet { value: implValue, source: data.source, filePath: data.filePath, + // Where the value sits in its file's module namespace (#484), resolved by the wrapper. + exportPath: data.exportPath ?? null, + memberExportPaths: data.memberExportPaths ?? null, collisionMode: collisionMode }); // #398: ownership has now decided placement — surface the PUBLIC, post-placement @@ -518,8 +521,18 @@ class Slothlet { value: implValue, source: data.source, filePath: data.filePath, + exportPath: data.exportPath ?? null, + memberExportPaths: data.memberExportPaths ?? null, collisionMode: collisionMode }); + } else { + // Same owner, new impl (a lazy leaf materializing, a hot reload): the entry stays put, but + // its module origin follows the impl that actually arrived (#484). + this.handlers.ownership.refreshOrigin(data.moduleID, data.apiPath, { + filePath: data.filePath, + exportPath: data.exportPath ?? null, + memberExportPaths: data.memberExportPaths ?? null + }); } // #398: surface the PUBLIC, post-placement impl:changed — only when this contribution is // the placed owner of the path (a change to a shadowed/merge-lost contribution is not a @@ -1450,8 +1463,11 @@ class Slothlet { * API instance with its own component graph, context store, and lifecycle. * @alias module:@cldmv/slothlet * @async + * @template {object} [T=import("./lib/runtime/runtime.mjs").SlothletSelf] - The loaded api's own shape. + * Defaults to `SlothletSelf`, which a `slothlet typegen` declaration extends with the project's api; + * pass another interface (`slothlet(...)`) when a program loads more than one api. * @param {SlothletOptions} config - Configuration options - * @returns {Promise} Fully loaded, proxy-based API object + * @returns {Promise} Fully loaded, proxy-based API object * @public * @example * // Minimal usage diff --git a/tests/validate-typestubs.mjs b/tests/validate-typestubs.mjs index 2cefe6eae..77999281d 100644 --- a/tests/validate-typestubs.mjs +++ b/tests/validate-typestubs.mjs @@ -67,16 +67,59 @@ async function check() { export default check; `; +// A project's typed api through the stubs (#484): `slothlet typegen` writes a declaration whose members +// reference the project's own leaves and which extends `SlothletSelf`, so an imported `self` and an +// awaited `slothlet()` are both typed — with no annotation — once the satellite is installed. +const TYPED_LEAF = `/** + * @param {number} a + * @param {number} b + * @returns {number} + */ +export function add(a, b) { + return a + b; +} +`; + +const TYPED_CONSUMER = ` +import { self } from "@cldmv/slothlet/runtime"; +import slothlet from "@cldmv/slothlet"; + +export const viaSelf: number = self.math.add(1, 2); +export const reload: Function = self.slothlet.api.reload; +// @ts-expect-error wrong argument type +self.math.add("1", 2); +// @ts-expect-error missing member +self.math.subtract(1, 2); + +export async function viaApi(): Promise { + const api = await slothlet({ base: "./api" }); + const sum: number = api.math.add(1, 2); + await api.slothlet.shutdown(); + // @ts-expect-error wrong argument type + api.math.add("1", 2); + // @ts-expect-error missing member + api.nope(); + return sum; +} +`; + // Validate under both the lax (bundler) and the strict ESM (nodenext) resolvers — nodenext is the // common case for ESM-only consumers and is far pickier about subpath exports and conditions. const RESOLUTIONS = ["bundler", "nodenext"]; -/** Run tsc on a single file under a given moduleResolution (production/default condition). @returns {{ok:boolean, out:string}} */ -function tsc(testFile, resolution) { +/** + * Run tsc on one or more files under a given moduleResolution (production/default condition). + * @param {string|string[]} testFiles - File(s) to compile together. + * @param {string} resolution - `bundler` or `nodenext`. + * @param {string} [extraFlags=""] - Additional compiler flags (e.g. `--allowJs`). + * @returns {{ok:boolean, out:string}} + */ +function tsc(testFiles, resolution, extraFlags = "") { const moduleFlag = resolution === "nodenext" ? "nodenext" : "esnext"; + const files = (Array.isArray(testFiles) ? testFiles : [testFiles]).map((file) => `"${file}"`).join(" "); try { const out = execSync( - `npx tsc --noEmit --strict --moduleResolution ${resolution} --module ${moduleFlag} --target es2022 "${testFile}"`, + `npx tsc --noEmit --strict --moduleResolution ${resolution} --module ${moduleFlag} --target es2022 ${extraFlags} ${files}`, { stdio: "pipe", encoding: "utf8", cwd: projectRoot } ); return { ok: true, out }; @@ -137,6 +180,30 @@ function main() { } } + // 1d) Typed `self` + typed `slothlet()` through the stubs (#484): generate a declaration with the + // real `slothlet typegen` CLI for a one-leaf api, then check a consumer that imports `self` and + // awaits `slothlet()` — positive calls type-check and every @ts-expect-error line is an error. + const typedDir = join(tmpDir, "typed"); + mkdirSync(join(typedDir, "api"), { recursive: true }); + writeFileSync(join(typedDir, "api", "math.mjs"), TYPED_LEAF, "utf8"); + const typedDeclaration = join(typedDir, "api.d.mts"); + execFileSync( + "node", + [join(projectRoot, "bin", "slothlet.mjs"), "typegen", "-d", join(typedDir, "api"), "-o", typedDeclaration, "-n", "ConsumerApi"], + { stdio: "pipe", cwd: typedDir } + ); + const typedConsumer = join(typedDir, "consumer.mts"); + writeFileSync(typedConsumer, TYPED_CONSUMER, "utf8"); + for (const res of RESOLUTIONS) { + const typed = tsc([typedDeclaration, typedConsumer], res, "--allowJs"); + if (typed.ok) { + console.log(`✅ [${res}] typegen declaration types \`self\` and the slothlet() result through the stubs`); + } else { + failed = true; + console.error(`❌ [${res}] expected the typed self/api consumer to type-check through the stubs:\n` + typed.out); + } + } + // 1c) An internal-only subpath NOT carried by the satellite ships a self-contained declaration // instead of a re-export (build-typestubs.mjs). Verify one of those — ./modes/eager, whose // declaration imports the package-internal #factories/component-base — actually type-checks diff --git a/tests/vitests/suites/typegen/typegen-cli.test.vitest.mjs b/tests/vitests/suites/typegen/typegen-cli.test.vitest.mjs index 69bccae03..41c113d52 100644 --- a/tests/vitests/suites/typegen/typegen-cli.test.vitest.mjs +++ b/tests/vitests/suites/typegen/typegen-cli.test.vitest.mjs @@ -135,7 +135,7 @@ describe("slothlet CLI", () => { expect(existsSync(out)).toBe(true); const content = await readFile(out, "utf8"); expect(content).toContain("interface PositionalApi"); - expect(content).toContain("declare const self"); + expect(content).toContain("interface SlothletSelf extends PositionalApi {}"); }); }); @@ -265,6 +265,43 @@ describe("slothlet CLI", () => { expect(stderr).toMatch(/requires a value/i); }); }); + + describe("typegen self typing (#484)", () => { + it("extends SlothletSelf by default", async () => { + const tmp = await freshTempDir(); + const out = path.join(tmp, "api.d.ts"); + const { code } = await runCli(["typegen", "-d", API_FIXTURE, "-o", out, "-n", "SelfApi"]); + expect(code).toBe(0); + const content = await readFile(out, "utf8"); + expect(content).toContain('declare module "@cldmv/slothlet/runtime" {'); + expect(content).toContain("interface SlothletSelf extends SelfApi {}"); + }); + + it("--no-augment-runtime leaves SlothletSelf alone", async () => { + const tmp = await freshTempDir(); + const out = path.join(tmp, "api.d.ts"); + const { code } = await runCli(["typegen", "-d", API_FIXTURE, "-o", out, "-n", "NoSelfApi", "--no-augment-runtime"]); + expect(code).toBe(0); + const content = await readFile(out, "utf8"); + expect(content).toContain("interface NoSelfApi"); + expect(content).not.toContain("SlothletSelf"); + }); + + it("reads augmentRuntime from package.json even when the other options come from flags", async () => { + const tmp = await freshTempDir(); + const out = path.join(tmp, "api.d.ts"); + await writeFile( + path.join(tmp, "package.json"), + JSON.stringify({ name: "tmp-cli-fixture", slothlet: { typegen: { augmentRuntime: false } } }), + "utf8" + ); + const { code } = await runCli(["typegen", "-d", API_FIXTURE, "-o", out, "-n", "PkgNoSelfApi"], { cwd: tmp }); + expect(code).toBe(0); + const content = await readFile(out, "utf8"); + expect(content).toContain("interface PkgNoSelfApi"); + expect(content).not.toContain("SlothletSelf"); + }); + }); }); function escapeRegex(s) { diff --git a/tests/vitests/suites/typegen/typegen-origin.test.vitest.mjs b/tests/vitests/suites/typegen/typegen-origin.test.vitest.mjs new file mode 100644 index 000000000..6c5f15361 --- /dev/null +++ b/tests/vitests/suites/typegen/typegen-origin.test.vitest.mjs @@ -0,0 +1,357 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/typegen/typegen-origin.test.vitest.mjs + * @Date: 2026-09-28 00:19:50 -07:00 (1790579990) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:53:46 -07:00 (1790582026) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Typegen types the api slothlet actually composes (#484). + * + * Every member of a generated declaration must reference the export slothlet placed at that path — + * `typeof import("")["k"]` — read from the module origin the ownership records carry, so a + * tree mixing `.mjs`, `.cjs` and `.mts` leaves (named, default, CommonJS, flattened, callable + * namespaces, object leaves, non-function values) is typed exactly. The acceptance checks compile a + * probe that imports `self` from `@cldmv/slothlet/runtime` and awaits `slothlet()` against the + * generated file, under both NodeNext and Bundler resolution with `allowJs`, asserting the positive + * calls type-check and every `@ts-expect-error` line really is an error. + */ +import { describe, it, expect, afterAll, vi } from "vitest"; +import { mkdir, writeFile, readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import ts from "typescript"; +import slothlet from "@cldmv/slothlet"; +import { generateTypes } from "@cldmv/slothlet/typegen"; +import { generateTypes as generateFromInstance } from "@cldmv/slothlet/processors/type-generator"; +import { resolveWrapper } from "#handlers/unified-wrapper"; +import { makeTestTmpDir } from "../../setup/test-fixtures-tmp.mjs"; + +// Each compile builds a full TypeScript program over slothlet's own declarations with lib checking on +// (the generated file is itself a .d.mts, so skipLibCheck would skip exactly what is under test). +vi.setConfig({ testTimeout: 120000, hookTimeout: 120000 }); + +const FIXTURE = path.resolve("api_tests/api_test_typegen_mixed"); + +const tempRoots = []; + +afterAll(async () => { + await Promise.allSettled(tempRoots.map((root) => rm(root, { recursive: true, force: true }))); +}); + +/** + * Create a scratch directory inside the package (so `@cldmv/slothlet` self-resolves from it). + * @returns {Promise} Absolute directory path. + */ +async function freshTempDir() { + const root = await makeTestTmpDir("typegen-origin"); + tempRoots.push(root); + return root; +} + +/** + * Compile files and return every diagnostic outside `node_modules`, formatted. + * @param {string[]} files - Root files of the program. + * @param {"nodenext"|"bundler"} resolution - Module resolution strategy. + * @returns {string[]} Formatted diagnostics (empty = the program type-checks). + */ +function compile(files, resolution) { + const nodeNext = resolution === "nodenext"; + const program = ts.createProgram(files, { + noEmit: true, + strict: true, + allowJs: true, + skipLibCheck: false, + target: ts.ScriptTarget.ES2022, + module: nodeNext ? ts.ModuleKind.NodeNext : ts.ModuleKind.ESNext, + moduleResolution: nodeNext ? ts.ModuleResolutionKind.NodeNext : ts.ModuleResolutionKind.Bundler, + // Resolve @cldmv/slothlet(/runtime) to this checkout's own declarations (types/src/…). + customConditions: ["slothlet-dev"], + types: ["node"] + }); + return ts + .getPreEmitDiagnostics(program) + .filter((d) => !d.file || !d.file.fileName.includes(`${path.sep}node_modules${path.sep}`)) + .map((d) => { + const text = ts.flattenDiagnosticMessageText(d.messageText, "\n"); + if (!d.file || d.start === undefined) return text; + const { line } = d.file.getLineAndCharacterOfPosition(d.start); + return `${path.basename(d.file.fileName)}:${line + 1} ${text}`; + }); +} + +/** A TypeScript probe exercising every leaf shape of the mixed fixture through `self` and `slothlet()`. */ +const TS_PROBE = `import { self } from "@cldmv/slothlet/runtime"; +import slothlet from "@cldmv/slothlet"; +import type { EventEmitter } from "node:events"; + +export function useSelf() { + const sum: number = self.math.add(1, 2); + const repeated: string = self.math.repeat("a", 3); + const greeting: string = self.greet("x"); + const retries: number = self.settings.LIMITS.maxRetries; + const mode: "strict" | "lenient" = self.settings.LIMITS.mode; + const version: string = self.settings.VERSION; + const shouted: string = self.text.shout("x"); + const words: number = self.text.stats.words("a b"); + const even: boolean = self.parity(2); + const level: number = self.bulb.brightness.get(); + self.bulb.brightness.set(3); + const events: EventEmitter = self.bulb.events(); + const widget: { id: number; name: string } = self.widgets.create("w"); + const widgetCount: number = self.widgets.count(); + const logged: string = self.logger("m"); + const info: string = self.logger.info("m"); + const logLevel: number = self.logger.LEVEL; + const step: 5 = self.tuning.TUNE_STEP; + const gain: 2 = self.tuning.defaults.DEFAULT_GAIN; + const cascade: Promise = self.initialize(); + const reload: Function = self.slothlet.api.reload; + + // @ts-expect-error named export: wrong argument type + self.math.add("1", 2); + // @ts-expect-error missing member + self.math.subtract(1, 2); + // @ts-expect-error default export: wrong argument type + self.greet(1); + // @ts-expect-error CommonJS module.exports = function: wrong argument type + self.parity("2"); + // @ts-expect-error CommonJS object export member: wrong argument type + self.text.shout(1); + // @ts-expect-error CommonJS nested object leaf: wrong argument type + self.text.stats.words(1); + // @ts-expect-error interface-annotated object leaf: wrong argument type + self.bulb.brightness.set("bright"); + // @ts-expect-error frozen constant is readonly + self.settings.LIMITS.maxRetries = 4; + // @ts-expect-error callable namespace: wrong argument type + self.logger(1); + // @ts-expect-error callable namespace member: wrong argument type + self.logger.info(2); + // @ts-expect-error flattened self-named file: wrong argument type + self.widgets.create(3); + // @ts-expect-error folder-namespace primitive is its literal export type + const wrongStep: string = self.tuning.TUNE_STEP; + // @ts-expect-error missing namespace + self.nope.call(); + + return { sum, repeated, greeting, retries, mode, version, shouted, words, even, level, events, widget, widgetCount, logged, info, logLevel, step, gain, wrongStep, cascade, reload }; +} + +export async function useApi() { + const api = await slothlet({ base: "./api" }); + const sum: number = api.math.add(1, 2); + const even: boolean = api.parity(4); + await api.slothlet.shutdown(); + // @ts-expect-error wrong argument type through the consumer api + api.math.add("1", 2); + // @ts-expect-error missing member on the consumer api + api.nope(); + return { sum, even }; +} +`; + +/** The same checks from a JSDoc-checked `.mjs` leaf. */ +const JS_PROBE = `// @ts-check +import { self } from "@cldmv/slothlet/runtime"; + +export function useSelfFromJs() { + /** @type {number} */ + const sum = self.math.add(1, 2); + /** @type {string} */ + const shouted = self.text.shout("x"); + /** @type {boolean} */ + const even = self.parity(2); + // @ts-expect-error wrong argument type + self.math.add("1", 2); + // @ts-expect-error missing member + self.text.whisper("x"); + return { sum, shouted, even }; +} +`; + +describe("typegen types the composed api from each node's module origin (#484)", () => { + it("records each node's exportPath in the ownership records", async () => { + const api = await slothlet({ base: FIXTURE, mode: "eager", typescript: { mode: "fast" }, silent: true }); + try { + const ownership = resolveWrapper(api.math).slothlet.handlers.ownership; + const origin = (apiPath) => { + const found = ownership.getOrigin(apiPath); + return found && { file: path.relative(FIXTURE, found.filePath).split(path.sep).join("/"), exportPath: found.exportPath }; + }; + expect(origin("math.add")).toEqual({ file: "math.mjs", exportPath: ["add"] }); + expect(origin("greet")).toEqual({ file: "greet.mjs", exportPath: ["default"] }); + expect(origin("settings.LIMITS")).toEqual({ file: "settings.mjs", exportPath: ["LIMITS"] }); + expect(origin("settings.LIMITS.maxRetries")).toEqual({ file: "settings.mjs", exportPath: ["LIMITS", "maxRetries"] }); + expect(origin("settings.VERSION")).toEqual({ file: "settings.mjs", exportPath: ["VERSION"] }); + // CommonJS: the loader exposes module.exports as `default`. + expect(origin("text")).toEqual({ file: "text.cjs", exportPath: ["default"] }); + expect(origin("text.shout")).toEqual({ file: "text.cjs", exportPath: ["default", "shout"] }); + expect(origin("text.stats.words")).toEqual({ file: "text.cjs", exportPath: ["default", "stats", "words"] }); + expect(origin("parity")).toEqual({ file: "parity.cjs", exportPath: ["default"] }); + expect(origin("bulb.brightness")).toEqual({ file: "bulb.mts", exportPath: ["brightness"] }); + expect(origin("bulb.brightness.set")).toEqual({ file: "bulb.mts", exportPath: ["brightness", "set"] }); + // Self-named file flattened into its folder namespace. + expect(origin("widgets.create")).toEqual({ file: "widgets/widgets.mjs", exportPath: ["create"] }); + // Callable namespace: the default function, with the named exports merged onto it. + expect(origin("logger")).toEqual({ file: "logger.mjs", exportPath: ["default"] }); + expect(origin("logger.info")).toEqual({ file: "logger.mjs", exportPath: ["info"] }); + expect(origin("logger.LEVEL")).toEqual({ file: "logger.mjs", exportPath: ["LEVEL"] }); + // A primitive merged into a folder namespace that several files compose — it has no identity to + // look up and the folder namespace records no per-key map, so it is located by its own file. + expect(origin("tuning.TUNE_STEP")).toEqual({ file: "tuning/tuning.mjs", exportPath: ["TUNE_STEP"] }); + expect(origin("tuning.defaults.DEFAULT_GAIN")).toEqual({ file: "tuning/defaults.mjs", exportPath: ["DEFAULT_GAIN"] }); + // A namespace slothlet composed from several named exports is not itself an export. + expect(origin("math").exportPath).toBeNull(); + } finally { + await api.slothlet.shutdown(); + } + }); + + it("keeps a primitive's origin in a multi-file namespace when a reload changes its value", async () => { + const root = await freshTempDir(); + const tuning = path.join(root, "api", "tuning"); + await mkdir(tuning, { recursive: true }); + const writeTuning = (step) => + writeFile( + path.join(tuning, "tuning.mjs"), + `export function tune(v) { return v * TUNE_STEP; }\nexport const TUNE_STEP = ${step};\n`, + "utf8" + ); + await writeTuning(5); + await writeFile(path.join(tuning, "defaults.mjs"), "export const DEFAULT_GAIN = 2;\n", "utf8"); + + const api = await slothlet({ base: path.join(root, "api"), mode: "eager", silent: true }); + try { + const ownership = resolveWrapper(api.tuning).slothlet.handlers.ownership; + expect(api.tuning.TUNE_STEP).toBe(5); + expect(ownership.getOrigin("tuning.TUNE_STEP")?.exportPath).toEqual(["TUNE_STEP"]); + + // The primitive's value changes on disk; after the reload it must still resolve to its export. + // A full reload rebuilds the ownership records; a scoped reload keeps them and re-indexes the file. + await writeTuning(7); + await api.slothlet.api.reload("tuning"); + expect(api.tuning.TUNE_STEP).toBe(7); + expect(ownership.getOrigin("tuning.TUNE_STEP")?.exportPath).toEqual(["TUNE_STEP"]); + + await writeTuning(9); + await api.slothlet.reload(); + expect(api.tuning.TUNE_STEP).toBe(9); + expect(ownership.getOrigin("tuning.TUNE_STEP")?.exportPath).toEqual(["TUNE_STEP"]); + } finally { + await api.slothlet.shutdown(); + } + }); + + it("emits typeof-import references to the real exports, mapping extensions and CommonJS roots", async () => { + const tmp = await freshTempDir(); + const output = path.join(tmp, "types", "api.d.mts"); + const { content } = await generateTypes({ dir: FIXTURE, output, interfaceName: "MixedApi" }); + const spec = (file) => JSON.stringify(path.relative(path.dirname(output), path.join(FIXTURE, file)).split(path.sep).join("/")); + + expect(content).toContain(`add: typeof import(${spec("math.mjs")})["add"];`); + expect(content).toContain(`greet: typeof import(${spec("greet.mjs")})["default"];`); + expect(content).toContain(`LIMITS: typeof import(${spec("settings.mjs")})["LIMITS"];`); + // CommonJS: `import("x.cjs")` IS module.exports, so the loader's `default` segment is dropped. + expect(content).toContain(`text: typeof import(${spec("text.cjs")});`); + expect(content).toContain(`parity: typeof import(${spec("parity.cjs")});`); + // .mts leaves are imported by their runtime extension. + expect(content).toContain(`brightness: typeof import(${spec("bulb.mjs")})["brightness"];`); + expect(content).not.toContain("bulb.mts"); + // Callable namespace: the default function intersected with the members merged onto it. + expect(content).toContain(`logger: typeof import(${spec("logger.mjs")})["default"] & {`); + expect(content).toContain(`info: typeof import(${spec("logger.mjs")})["info"];`); + expect(content).toContain(`create: typeof import(${spec("widgets/widgets.mjs")})["create"];`); + expect(content).toContain(`TUNE_STEP: typeof import(${spec("tuning/tuning.mjs")})["TUNE_STEP"];`); + expect(content).not.toContain("unknown;"); + // Nothing falls back to an untyped signature. + expect(content).not.toMatch(/\bany\b/); + expect(content).toContain('import type { SlothletAPI } from "@cldmv/slothlet";'); + expect(content).toContain("interface SlothletSelf extends MixedApi {}"); + expect(content).toContain("interface SlothletSelf extends SlothletAPI {}"); + }); + + for (const resolution of ["nodenext", "bundler"]) { + it(`types self and the slothlet() result under ${resolution} resolution`, async () => { + const tmp = await freshTempDir(); + const output = path.join(tmp, "types", "api.d.mts"); + await generateTypes({ dir: FIXTURE, output, interfaceName: "MixedApi" }); + const tsProbe = path.join(tmp, "probe.mts"); + const jsProbe = path.join(tmp, "probe.mjs"); + await writeFile(tsProbe, TS_PROBE, "utf8"); + await writeFile(jsProbe, JS_PROBE, "utf8"); + + expect(compile([output, tsProbe, jsProbe], resolution)).toEqual([]); + // Control: without the generated declaration `self` is the bare anchor, so the same probe fails — + // the zero above is the declaration's doing, not an `any` letting everything through. + expect(compile([tsProbe], resolution).length).toBeGreaterThan(0); + }); + } + + it("types a folder mounted at runtime with api.slothlet.api.add()", async () => { + const tmp = await freshTempDir(); + const output = path.join(tmp, "mounted.d.mts"); + const api = await slothlet({ + base: path.join(FIXTURE, "widgets"), + mode: "eager", + typescript: { mode: "fast" }, + silent: true + }); + try { + await api.slothlet.api.add("plugins.mixed", FIXTURE); + const { output: content } = await generateFromInstance(api, { output, interfaceName: "MountedApi" }); + const spec = (file) => JSON.stringify(path.relative(tmp, path.join(FIXTURE, file)).split(path.sep).join("/")); + expect(content).toContain(`add: typeof import(${spec("math.mjs")})["add"];`); + expect(content).toContain(`parity: typeof import(${spec("parity.cjs")});`); + + const probe = path.join(tmp, "mounted-probe.mts"); + await writeFile( + probe, + [ + 'import { self } from "@cldmv/slothlet/runtime";', + "export const sum: number = self.plugins.mixed.math.add(1, 2);", + "export const even: boolean = self.plugins.mixed.parity(2);", + "export const created: { id: number; name: string } = self.widgets.create('w');", + "// @ts-expect-error mounted leaf: wrong argument type", + 'self.plugins.mixed.math.add("1", 2);', + "// @ts-expect-error not mounted", + "self.plugins.other.call();", + "" + ].join("\n"), + "utf8" + ); + expect(compile([output, probe], "nodenext")).toEqual([]); + } finally { + await api.slothlet.shutdown(); + } + }); + + it("types a member with no module origin as unknown and says why", async () => { + const tmp = await freshTempDir(); + const output = path.join(tmp, "no-origin.d.mts"); + const api = await slothlet({ base: path.join(FIXTURE, "widgets"), mode: "eager", silent: true }); + try { + await api.slothlet.api.add("services", { exports: { now: () => Date.now() } }); + const { output: content } = await generateFromInstance(api, { output, interfaceName: "NoOriginApi" }); + expect(content).toMatch(/\/\*\* No module origin: [^\n]*\*\/\n\t\tnow: unknown;/); + expect(content).not.toMatch(/\bany\b/); + } finally { + await api.slothlet.shutdown(); + } + }); + + it("leaves SlothletAPI out when the runtime augmentation is off", async () => { + const tmp = await freshTempDir(); + const output = path.join(tmp, "plain.d.mts"); + await generateTypes({ dir: path.join(FIXTURE, "widgets"), output, interfaceName: "PlainApi", augmentRuntime: false }); + const content = await readFile(output, "utf8"); + expect(content).toContain("export interface PlainApi {"); + expect(content).not.toContain("SlothletAPI"); + expect(content).not.toContain("SlothletSelf"); + }); +}); diff --git a/tests/vitests/suites/typegen/typegen-programmatic.test.vitest.mjs b/tests/vitests/suites/typegen/typegen-programmatic.test.vitest.mjs index bf5d5be5d..60639d3a4 100644 --- a/tests/vitests/suites/typegen/typegen-programmatic.test.vitest.mjs +++ b/tests/vitests/suites/typegen/typegen-programmatic.test.vitest.mjs @@ -64,7 +64,7 @@ describe("typegen programmatic API", () => { const content = await readFile(output, "utf8"); expect(content).toContain("interface RuntimeApi"); - expect(content).toContain("declare const self"); + expect(content).toContain("interface SlothletSelf extends RuntimeApi {}"); expect(content).toContain("RuntimeApi"); // All three fixture modules should appear somewhere in the declaration. expect(content).toMatch(/\bfoo\b/); @@ -119,12 +119,13 @@ describe("typegen programmatic API", () => { }); }); -// #213 — JSDoc `@param {T}` / `@returns {T}` types must be reflected into the generated signatures as -// PROPER, valid TypeScript. A normal slothlet module is plain `.mjs` with JSDoc-only types, and the -// generator previously read only inline annotation nodes, so every signature came out `any`. The fix -// resolves types through a real TypeScript Program + checker, so object shapes, optionals, unions, and -// generics come through. The acceptance test type-checks the generated `.d.ts` itself — the whole point -// of typegen is a declaration a consumer can run `tsc` against. +// #213 — JSDoc `@param {T}` / `@returns {T}` types must reach the generated declaration as PROPER, +// valid TypeScript. A normal slothlet module is plain `.mjs` with JSDoc-only types, and the generator +// once read only inline annotation nodes, so every signature came out `any`. The declaration now +// references each leaf's own export (#484), so TypeScript reads the JSDoc / annotations directly. The +// acceptance check compiles a probe against the generated declaration: calls that must type-check, plus +// `@ts-expect-error` lines proving each type is real — the whole point of typegen is a declaration a +// consumer can run `tsc` against. describe("typegen JSDoc type reflection (#213)", () => { async function generateFromApi(files) { const tmp = await freshTempDir(); @@ -138,33 +139,50 @@ describe("typegen JSDoc type reflection (#213)", () => { return { content, outPath }; } - // Compile the generated declaration under strict mode and return the diagnostic messages (empty = - // valid, usable TypeScript). This is what actually proves "proper types" — a string match does not. - function declarationDiagnostics(dtsPath) { - const program = ts.createProgram([dtsPath], { + // Compile a probe that imports `self` against the generated declaration, under strict mode with + // `allowJs` (the declaration references the `.mjs` leaves directly) and the `slothlet-dev` condition + // (so `@cldmv/slothlet/runtime` resolves to this checkout's declarations). Returns every diagnostic + // outside node_modules — empty means the calls type-check AND every `@ts-expect-error` line is a real + // error (an unused one is itself a diagnostic). skipLibCheck stays off: the generated file is a .d.mts. + async function probeDiagnostics(dtsPath, probeSource) { + const probePath = path.join(path.dirname(dtsPath), "probe.mts"); + await writeFile(probePath, `import { self } from "@cldmv/slothlet/runtime";\n${probeSource}`, "utf8"); + const program = ts.createProgram([dtsPath, probePath], { noEmit: true, - // Fully check the generated declaration itself — skipLibCheck:true would skip semantic - // checking of every .d.ts (ours included), so a broken declaration could still read as [] - // and defeat the acceptance test. Lib-file noise is excluded by filtering to dtsPath below. skipLibCheck: false, strict: true, - target: ts.ScriptTarget.Latest, - moduleResolution: ts.ModuleResolutionKind.Bundler + allowJs: true, + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + customConditions: ["slothlet-dev"], + types: ["node"] }); - const resolved = path.resolve(dtsPath); return ts .getPreEmitDiagnostics(program) - .filter((d) => !d.file || path.resolve(d.file.fileName) === resolved) + .filter((d) => !d.file || !d.file.fileName.includes(`${path.sep}node_modules${path.sep}`)) .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n")); } it("reflects primitive @param / @returns types, and the declaration compiles", async () => { - const { content, outPath } = await generateFromApi({ + const { outPath } = await generateFromApi({ "greet.mjs": "/**\n * @param {string} name\n * @param {number} times\n * @returns {string}\n */\nexport function greet(name, times) {\n\treturn name.repeat(times);\n}\n" }); - expect(content).toContain("greet(name: string, times: number): string"); - expect(declarationDiagnostics(outPath)).toEqual([]); + const diagnostics = await probeDiagnostics( + outPath, + [ + 'export const greeting: string = self.greet("hi", 2);', + "// @ts-expect-error name is a string", + "self.greet(1, 2);", + "// @ts-expect-error times is a number", + 'self.greet("hi", "2");', + "// @ts-expect-error returns a string", + 'export const wrong: number = self.greet("hi", 2);', + "" + ].join("\n") + ); + expect(diagnostics).toEqual([]); }); it("resolves object params (dotted @param), optionals, unions, arrays, generics, and const-arrow exports into valid TS", async () => { @@ -178,50 +196,85 @@ describe("typegen JSDoc type reflection (#213)", () => { "misc.mjs": '/**\n * @param {boolean} flag\n * @returns {number}\n */\nexport const toNum = (flag) => (flag ? 1 : 0);\nexport const LABEL = "x";\n' }); - // The compiled declaration is valid TypeScript — the real regression guard (raw JSDoc used to leak - // in and break compilation). - expect(declarationDiagnostics(outPath)).toEqual([]); expect(content).not.toMatch(/@param/); // no raw JSDoc leaked into the output - // object shape resolved from the dotted @param tags (not `any`, not comment text) - expect(content).toMatch(/build\(opts:\s*\{[^}]*name:\s*string/); - expect(content).toMatch(/count\?:\s*number/); // optional sub-property - expect(content).toMatch(/suffix\?:\s*string/); // bracket-optional param - expect(content).toContain("lookup(id: string | number, tags: string[]): Promise<{ ok: boolean; }>"); - expect(content).toContain("toNum(flag: boolean): number"); // const-arrow export + const diagnostics = await probeDiagnostics( + outPath, + [ + // object shape resolved from the dotted @param tags; optional sub-property and optional param + 'export const built: string = self.build({ name: "n" });', + 'export const builtFull: string = self.build({ name: "n", count: 2 }, "-s");', + "// @ts-expect-error opts.name is required", + "self.build({ count: 2 });", + "// @ts-expect-error opts.count is a number", + 'self.build({ name: "n", count: "2" });', + "// @ts-expect-error suffix is a string", + 'self.build({ name: "n" }, 3);', + // union + array params, generic return + 'export const found: Promise<{ ok: boolean }> = self.lookup("a", ["x"]);', + "export const foundByNumber: Promise<{ ok: boolean }> = self.lookup(1, []);", + "// @ts-expect-error id is string | number", + 'self.lookup(true, ["x"]);', + "// @ts-expect-error tags is string[]", + 'self.lookup("a", [1]);', + "// @ts-expect-error the generic resolves to Promise<{ ok: boolean }>", + 'export const wrongGeneric: Promise<{ ok: string }> = self.lookup("a", []);', + // const-arrow export + "export const asNumber: number = self.misc.toNum(true);", + "// @ts-expect-error flag is a boolean", + 'self.misc.toNum("yes");', + "" + ].join("\n") + ); + expect(diagnostics).toEqual([]); }); - it("falls back to a valid signature when a function has no JSDoc types", async () => { - const { content, outPath } = await generateFromApi({ + it("falls back to accepting any arguments when a function has no JSDoc types", async () => { + const { outPath } = await generateFromApi({ "bare.mjs": "export function bare(a, b) {\n\treturn `${a}${b}`;\n}\n" }); - expect(content).toMatch(/bare\(a: any, b: any\):/); // untyped params → any (still valid) - expect(declarationDiagnostics(outPath)).toEqual([]); + // untyped params → any: calls with arbitrary argument types compile + const diagnostics = await probeDiagnostics( + outPath, + [ + 'self.bare(1, "x");', + "self.bare({}, [true]);", + "self.bare(null, undefined);", + // …while self itself is typed (not `any`): an unknown member is still an error + "// @ts-expect-error missing member", + "self.nope();", + "" + ].join("\n") + ); + expect(diagnostics).toEqual([]); }); - // A TypeScript leaf can reference a LOCAL named type (interface / type alias / enum) declared in - // the same file. The checker prints that type by name, so the generated declaration referenced an - // undefined name and failed to compile until the generator learned to emit the referenced local - // type declarations alongside the interface. - it("emits referenced local named types from a TypeScript leaf so the declaration compiles", async () => { - const { content, outPath } = await generateFromApi({ - // `Meta` is referenced only *inside* `Shape` (never in a signature) — it must still be pulled - // in transitively. `Unused` is referenced by nothing and must NOT appear. + // A TypeScript leaf can reference a LOCAL named type (interface / type alias / enum) declared in the + // same file. The declaration references the leaf's export, so TypeScript resolves those local types + // in the leaf itself — including ones reached only transitively (Meta, through Shape). + it("resolves referenced local named types from a TypeScript leaf so the declaration compiles", async () => { + const { outPath } = await generateFromApi({ + // `Meta` is referenced only *inside* `Shape` (never in a signature). `Unused` is referenced by nothing. "geo.mts": 'interface Point {\n\tx: number;\n\ty: number;\n\tlabel?: string;\n}\ninterface Meta {\n\ttag: string;\n}\ntype Shape = { origin: Point; kind: "box" | "circle"; meta: Meta };\nenum Unit {\n\tPx,\n\tEm\n}\ninterface Unused {\n\tz: number;\n}\nexport function build(p: Point, s: Shape): { shape: Shape; ok: boolean } {\n\treturn { shape: s, ok: p.x > 0 };\n}\nexport function ids(unit: Unit): Point[] {\n\treturn [];\n}\n' }); - // Directly-referenced local types are emitted… - expect(content).toMatch(/interface Point/); - expect(content).toMatch(/type Shape/); - expect(content).toMatch(/enum Unit/); - // …and so are ones referenced only transitively (Meta, reached through Shape). - expect(content).toMatch(/interface Meta/); - // And the signatures still reference them by name. - expect(content).toMatch(/build\(p: Point, s: Shape\)/); - expect(content).toMatch(/ids\(unit: Unit\): Point\[\]/); - // A local type nothing references is NOT dragged in. - expect(content).not.toMatch(/interface Unused/); - // The whole declaration compiles under the strict acceptance check — the real regression guard - // (an undefined `Point` / `Shape` / `Unit` / `Meta` used to make tsc report "Cannot find name"). - expect(declarationDiagnostics(outPath)).toEqual([]); + const diagnostics = await probeDiagnostics( + outPath, + [ + "const point = { x: 1, y: 2 };", + 'const shape = { origin: point, kind: "box" as const, meta: { tag: "t" } };', + "export const result: { shape: { kind: string }; ok: boolean } = self.geo.build(point, shape);", + "export const points: { x: number; y: number; label?: string }[] = self.geo.ids(0);", + "// @ts-expect-error Point requires y", + "self.geo.build({ x: 1 }, shape);", + '// @ts-expect-error Shape.kind is "box" | "circle"', + 'self.geo.build(point, { ...shape, kind: "triangle" });', + "// @ts-expect-error Meta (reached through Shape) requires tag", + "self.geo.build(point, { ...shape, meta: {} });", + "// @ts-expect-error Unit is an enum, not a string", + 'self.geo.ids("px");', + "" + ].join("\n") + ); + expect(diagnostics).toEqual([]); }); }); diff --git a/tests/vitests/suites/typescript/type-generator.test.vitest.mjs b/tests/vitests/suites/typescript/type-generator.test.vitest.mjs index d37bcf283..c11ff6e53 100644 --- a/tests/vitests/suites/typescript/type-generator.test.vitest.mjs +++ b/tests/vitests/suites/typescript/type-generator.test.vitest.mjs @@ -20,11 +20,18 @@ * `src/lib/processors/type-generator.mjs`. */ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, afterAll, vi } from "vitest"; import fs from "fs"; import path from "path"; +import ts from "typescript"; +import slothlet from "@cldmv/slothlet"; import { generateTypes } from "@cldmv/slothlet/processors/type-generator"; import { withSuppressedSlothletErrorOutput } from "../../setup/vitest-helper.mjs"; +import { makeTestTmpDir } from "../../setup/test-fixtures-tmp.mjs"; + +// The real-instance cases compile a probe against the generated declaration (a full TypeScript program +// over slothlet's own declarations), which runs well past vitest's default budget under load. +vi.setConfig({ testTimeout: 120000, hookTimeout: 120000 }); // --------------------------------------------------------------------------- // Shared fixtures @@ -76,6 +83,68 @@ function buildMockAPI() { }; } +// --------------------------------------------------------------------------- +// Real-instance helpers (a composed api built from a small fixture written to a test tmp dir) +// --------------------------------------------------------------------------- + +const fixtureRoots = []; + +afterAll(() => { + for (const root of fixtureRoots) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** + * Write `files` into `/api`, compose them with slothlet (eager), and generate types for that live + * instance into `/api.d.mts`. + * @param {Record} files - File name → source. + * @param {string} [interfaceName="TestAPI"] - Generated interface name. + * @returns {Promise<{output: string, outputPath: string}>} The declaration and where it was written. + */ +async function generateForFixture(files, interfaceName = "TestAPI") { + const root = await makeTestTmpDir("type-generator"); + fixtureRoots.push(root); + const apiDir = path.join(root, "api"); + fs.mkdirSync(apiDir, { recursive: true }); + for (const [name, source] of Object.entries(files)) { + fs.writeFileSync(path.join(apiDir, name), source, "utf8"); + } + const outputPath = path.join(root, "api.d.mts"); + const api = await slothlet({ base: apiDir, mode: "eager", silent: true }); + try { + const { output } = await generateTypes(api, { output: outputPath, interfaceName }); + return { output, outputPath }; + } finally { + await api.slothlet.shutdown(); + } +} + +/** + * Compile a probe (importing `self`) against a generated declaration and return every diagnostic outside + * node_modules. Empty means the probe's calls type-check and each `@ts-expect-error` line is a real error. + * @param {string} dtsPath - Generated declaration. + * @param {string[]} probeLines - Probe body lines (after the `self` import). + * @returns {Promise} Diagnostic messages. + */ +async function probeDiagnostics(dtsPath, probeLines) { + const probePath = path.join(path.dirname(dtsPath), "probe.mts"); + fs.writeFileSync(probePath, ['import { self } from "@cldmv/slothlet/runtime";', ...probeLines, ""].join("\n"), "utf8"); + const program = ts.createProgram([dtsPath, probePath], { + noEmit: true, + skipLibCheck: false, + strict: true, + allowJs: true, + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + customConditions: ["slothlet-dev"], + types: ["node"] + }); + return ts + .getPreEmitDiagnostics(program) + .filter((d) => !d.file || !d.file.fileName.includes(`${path.sep}node_modules${path.sep}`)) + .map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n")); +} + // --------------------------------------------------------------------------- // Tmp directory helpers // --------------------------------------------------------------------------- @@ -179,7 +248,7 @@ describe("generateTypes – direct unit tests", () => { expect(output).toContain("export interface MySlothletAPI"); }); - it("should include the 'declare const self' line", async () => { + it("should extend SlothletSelf from @cldmv/slothlet/runtime with the interface", async () => { const outputPath = nextOutputPath(); const { output } = await generateTypes(buildMockAPI(), { @@ -187,7 +256,9 @@ describe("generateTypes – direct unit tests", () => { interfaceName: "MySlothletAPI" }); - expect(output).toContain("declare const self: MySlothletAPI"); + expect(output).toContain('declare module "@cldmv/slothlet/runtime" {'); + expect(output).toContain("interface SlothletSelf extends MySlothletAPI {}"); + expect(output).not.toContain("declare const self"); }); it("should include a @generated header comment", async () => { @@ -262,7 +333,7 @@ describe("generateTypes – direct unit tests", () => { ); expect(output).toContain("export interface EmptyAPI"); - expect(output).toContain("declare const self: EmptyAPI"); + expect(output).toContain("interface SlothletSelf extends EmptyAPI {}"); }); it("should skip API keys starting with _ (private/internal)", async () => { @@ -303,8 +374,7 @@ describe("generateTypes – direct unit tests", () => { }); it("should handle functions with no __metadata (no filePath) without throwing", async () => { - // traverseAPI still collects these nodes; extractTypesFromFile is skipped. - // The function now appears with a generic (...args: any[]): any signature. + // traverseAPI still collects these nodes; a node slothlet did not compose has no module origin. function plainFn() {} const apiNoMeta = { plainFn }; @@ -317,8 +387,8 @@ describe("generateTypes – direct unit tests", () => { // Generation must complete without error. expect(output).toContain("export interface TestAPI"); - // No filePath — falls back to generic signature. - expect(output).toContain("plainFn(...args: any[]): any"); + // No module origin — typed unknown, with the explanatory comment. + expect(output).toMatch(/\/\*\* No module origin: [^\n]*\*\/\n\tplainFn: unknown;/); }); it("should not recurse into circular references in the API", async () => { @@ -331,9 +401,9 @@ describe("generateTypes – direct unit tests", () => { await expect(generateTypes(circular, { output: outputPath, interfaceName: "TestAPI" })).resolves.not.toThrow(); }); - it("should use generic signature when __metadata.filePath does not exist (catch branch)", async () => { - // Points to a file that doesn't exist — fs.readFileSync throws, catch returns { exports: [] }. - // No name match is possible so the function gets (...args: any[]): any. + it("should emit unknown when __metadata.filePath does not exist", async () => { + // Points to a file that doesn't exist — the path in __metadata is not a module origin, so the + // function is typed unknown rather than from any file, and generation does not throw. function ghostFn() {} ghostFn.__metadata = { filePath: "/nonexistent/path/that/does/not/exist.ts" }; @@ -348,8 +418,8 @@ describe("generateTypes – direct unit tests", () => { ); expect(output).toContain("export interface TestAPI"); - // ghostFn has no extracted exports (catch returned []) so falls back to generic. - expect(output).toContain("ghostFn(...args: any[]): any"); + expect(output).toMatch(/\/\*\* No module origin: [^\n]*\*\/\n\tghostFn: unknown;/); + expect(output).not.toContain("does/not/exist"); }); it("should use 'any' fallback for untyped function parameters and return types", async () => { @@ -404,43 +474,37 @@ describe("generateTypes – direct unit tests", () => { /** Absolute path to the arrow-functions.ts API fixture. */ const arrowFilePath = path.resolve("api_tests/api_test_typescript_typegen/arrow-functions.ts"); - it("should extract signatures from arrow function exports (export const fn = (...) => ...)", async () => { - function double(x) { - return x * 2; - } - double.__metadata = { filePath: arrowFilePath }; - - const outputPath = nextOutputPath(); - const { output } = await generateTypes( - { double }, - { - output: outputPath, - interfaceName: "TestAPI" - } - ); + it("should type arrow function exports (export const fn = (...) => ...)", async () => { + const { output, outputPath } = await generateForFixture({ + "arrows.mjs": + "/**\n * @param {number} x\n * @returns {number}\n */\nexport const double = (x) => x * 2;\n\n/**\n * @param {boolean} value\n * @returns {boolean}\n */\nexport const negate = function (value) {\n\treturn !value;\n};\n" + }); - // Arrow export `double` has a typed signature — must appear in the interface - expect(output).toContain("double"); - expect(output).toContain("number"); + // Arrow export `double` references its own export — and that export's type is number → number. + expect(output).toMatch(/double: typeof import\("\.\/api\/arrows\.mjs"\)\["double"\];/); + expect( + await probeDiagnostics(outputPath, [ + "export const doubled: number = self.arrows.double(2);", + "// @ts-expect-error x is a number", + 'self.arrows.double("2");' + ]) + ).toEqual([]); }); - it("should extract signatures from function expression exports (export const fn = function(...))", async () => { - function negate(value) { - return !value; - } - negate.__metadata = { filePath: arrowFilePath }; - - const outputPath = nextOutputPath(); - const { output } = await generateTypes( - { negate }, - { - output: outputPath, - interfaceName: "TestAPI" - } - ); + it("should type function expression exports (export const fn = function(...))", async () => { + const { output, outputPath } = await generateForFixture({ + "arrows.mjs": + "/**\n * @param {number} x\n * @returns {number}\n */\nexport const double = (x) => x * 2;\n\n/**\n * @param {boolean} value\n * @returns {boolean}\n */\nexport const negate = function (value) {\n\treturn !value;\n};\n" + }); - expect(output).toContain("negate"); - expect(output).toContain("boolean"); + expect(output).toMatch(/negate: typeof import\("\.\/api\/arrows\.mjs"\)\["negate"\];/); + expect( + await probeDiagnostics(outputPath, [ + "export const negated: boolean = self.arrows.negate(true);", + "// @ts-expect-error value is a boolean", + "self.arrows.negate(1);" + ]) + ).toEqual([]); }); it("should include all arrow-function exports from a file alongside function declarations", async () => { @@ -475,52 +539,33 @@ describe("generateTypes – direct unit tests", () => { }); describe("Correct name-based signature matching", () => { - it("should assign each function its own signature, not the first export's", async () => { - // math.ts exports: add(a, b): number subtract(a, b): number multiply(a, b): number - // Previously the generator used exports[0] for all — every function got add's signature. - function mockAdd(a, b) { - return a + b; - } - mockAdd.__metadata = { filePath: mathFilePath }; - - function mockSubtract(a, b) { - return a - b; - } - mockSubtract.__metadata = { filePath: mathFilePath }; - - function mockMultiply(a, b) { - return a * b; - } - mockMultiply.__metadata = { filePath: mathFilePath }; - - const outputPath = nextOutputPath(); - const { output } = await generateTypes( - { add: mockAdd, subtract: mockSubtract, multiply: mockMultiply }, - { - output: outputPath, - interfaceName: "MathAPI" - } - ); - - // All three names must appear - expect(output).toContain("add"); - expect(output).toContain("subtract"); - expect(output).toContain("multiply"); - - // Each must have the correct signature — math.ts uses `number` everywhere - // Previously subtract and multiply would get add's signature; now each matches by name - const addLine = output.split("\n").find((l) => l.includes("add(")); - const subtractLine = output.split("\n").find((l) => l.includes("subtract(")); - const multiplyLine = output.split("\n").find((l) => l.includes("multiply(")); + it("should assign each function its own type, not the first export's", async () => { + // Three exports of one file with three different signatures: each api member must reference + // its own export — previously every function could inherit the first export's signature. + const { output, outputPath } = await generateForFixture({ + "calc.mjs": + "/**\n * @param {number} a\n * @param {number} b\n * @returns {number}\n */\nexport function add(a, b) {\n\treturn a + b;\n}\n\n/**\n * @param {string} text\n * @returns {string}\n */\nexport function label(text) {\n\treturn text;\n}\n\n/**\n * @param {boolean} on\n * @returns {boolean}\n */\nexport function toggle(on) {\n\treturn !on;\n}\n" + }); - expect(addLine).toContain("add("); - expect(subtractLine).toContain("subtract("); - expect(multiplyLine).toContain("multiply("); + expect(output).toMatch(/add: typeof import\("\.\/api\/calc\.mjs"\)\["add"\];/); + expect(output).toMatch(/label: typeof import\("\.\/api\/calc\.mjs"\)\["label"\];/); + expect(output).toMatch(/toggle: typeof import\("\.\/api\/calc\.mjs"\)\["toggle"\];/); + expect( + await probeDiagnostics(outputPath, [ + "export const sum: number = self.calc.add(1, 2);", + 'export const text: string = self.calc.label("x");', + "export const flipped: boolean = self.calc.toggle(true);", + "// @ts-expect-error label takes a string, not add's numbers", + "self.calc.label(1);", + "// @ts-expect-error toggle takes a boolean", + 'self.calc.toggle("on");' + ]) + ).toEqual([]); }); - it("should use a generic (...args: any[]): any signature when no export name matches the API key", async () => { - // API exposes the function as 'sum' but math.ts only exports 'add', 'subtract', 'multiply'. - // Should not steal add's signature — must fall back to the safe generic. + it("should emit unknown when the api key has no module origin, not borrow another export's type", async () => { + // API exposes the function as 'sum', and it carries a filePath to math.ts — but a node slothlet + // did not compose has no module origin, so nothing is matched by name against that file. function sum(a, b) { return a + b; } @@ -535,36 +580,36 @@ describe("generateTypes – direct unit tests", () => { } ); - // Must appear with the safe generic signature, not add's (a: number, b: number): number - expect(output).toContain("sum(...args: any[]): any"); - // Must NOT have stolen add's typed signature - expect(output).not.toContain("sum(a: number"); + // Typed unknown with the explanatory comment, never another export's type. + expect(output).toMatch(/\/\*\* No module origin: [^\n]*\*\/\n\tsum: unknown;/); + expect(output).not.toContain("typeof import"); }); }); }); -// ─── traverseAPI — primitive value (L141 arm1: else-if skipped) ────────────── - -describe("traverseAPI — primitive value in API skipped by both branches (L141 arm1)", () => { - it("does not include primitive values (number) in generated output", async () => { - // traverseAPI: `typeof value === "function"` → false, `typeof value === "object"` → false - // → neither branch fires for the primitive → arm1 of L141 else-if is hit - const apiWithPrimitive = { - count: 42, - label: "hello", - flag: true - }; +// ─── traverseAPI — primitive values are typed from their export ────────────── - const outputPath = nextOutputPath(); - const { output } = await generateTypes(apiWithPrimitive, { - output: outputPath, - interfaceName: "PrimitiveAPI" - }); +describe("traverseAPI — primitive value in API typed from its export", () => { + it("types primitive values (number) from their export in generated output", async () => { + const { output, outputPath } = await generateForFixture( + { + "counts.mjs": 'export const count = 42;\nexport const label = "hello";\nexport const flag = true;\n' + }, + "PrimitiveAPI" + ); - // Primitives are silently skipped — they don't appear in the interface expect(output).toContain("export interface PrimitiveAPI"); - expect(output).not.toContain("count"); - expect(output).not.toContain("label"); + expect(output).toMatch(/count: typeof import\("\.\/api\/counts\.mjs"\)\["count"\];/); + expect(output).toMatch(/label: typeof import\("\.\/api\/counts\.mjs"\)\["label"\];/); + expect(output).toMatch(/flag: typeof import\("\.\/api\/counts\.mjs"\)\["flag"\];/); + expect( + await probeDiagnostics(outputPath, [ + "export const count: 42 = self.counts.count;", + 'export const label: "hello" = self.counts.label;', + "// @ts-expect-error count is the number literal 42", + "export const wrong: string = self.counts.count;" + ]) + ).toEqual([]); }); }); diff --git a/tests/vitests/suites/typescript/typescript-strict-mode.test.vitest.mjs b/tests/vitests/suites/typescript/typescript-strict-mode.test.vitest.mjs index 15165877f..06da8d994 100644 --- a/tests/vitests/suites/typescript/typescript-strict-mode.test.vitest.mjs +++ b/tests/vitests/suites/typescript/typescript-strict-mode.test.vitest.mjs @@ -159,7 +159,7 @@ describe("TypeScript Strict Mode with Type Generation", () => { expect(content).toContain("capitalize"); }); - it("should declare self constant with interface type", async () => { + it("should type self by extending SlothletSelf with the interface", async () => { api = await slothlet({ base: "./api_tests/api_test_typescript", typescript: { @@ -172,7 +172,8 @@ describe("TypeScript Strict Mode with Type Generation", () => { }); const content = fs.readFileSync(outputPath, "utf8"); - expect(content).toContain("declare const self: TestAPI"); + expect(content).toContain('declare module "@cldmv/slothlet/runtime"'); + expect(content).toContain("interface SlothletSelf extends TestAPI {}"); }); }); diff --git a/types/index.d.mts b/types/index.d.mts index bd6a42882..34ea12155 100644 --- a/types/index.d.mts +++ b/types/index.d.mts @@ -1,12 +1,28 @@ export default slothlet; +/** + * The framework surface of a composed api: `api.slothlet.*`, `api.shutdown()`, `api.destroy()`. + * `slothlet typegen` output extends `SlothletSelf` with it, so `self.slothlet.*` is typed too. + */ +export type SlothletAPI = import("./src/slothlet.mjs").SlothletAPI; +/** + * The framework surface of a composed api: `api.slothlet.*`, `api.shutdown()`, `api.destroy()`. + * `slothlet typegen` output extends `SlothletSelf` with it, so `self.slothlet.*` is typed too. + * @typedef {import("./src/slothlet.mjs").SlothletAPI} SlothletAPI + */ /** * Creates a slothlet API instance with live-binding context and AsyncLocalStorage support. * Automatically wraps all API functions with context isolation for multi-instance support. + * + * The resolved api is typed as `SlothletAPI & T`. `T` defaults to `SlothletSelf` from + * `@cldmv/slothlet/runtime`, which a `slothlet typegen` declaration extends with the project's api — + * so a project with generated types gets a typed api with no annotation. A program that loads more + * than one api passes the interface explicitly: `slothlet({ base: "./other" })`. * @public * @async * + * @template {object} [T=import("./src/lib/runtime/runtime.mjs").SlothletSelf] * @param {import("./src/slothlet.mjs").SlothletOptions} [options={}] - Configuration options for the slothlet instance. See {@link SlothletOptions} for the full set. - * @returns {Promise} The bound API object with management methods + * @returns {Promise} The bound API object with management methods * * @example // ESM * import slothlet from "@cldmv/slothlet"; @@ -14,7 +30,7 @@ export default slothlet; * const result = await api.math.add(2, 3); // 5 * */ -export function slothlet(options?: import("./src/slothlet.mjs").SlothletOptions): Promise; +export function slothlet(options?: import("./src/slothlet.mjs").SlothletOptions): Promise; export namespace slothlet { let defaults: Readonly<{ routines: readonly (Readonly<{ diff --git a/types/index.d.mts.map b/types/index.d.mts.map index be3f1197d..79009d5b6 100644 --- a/types/index.d.mts.map +++ b/types/index.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../index.mjs"],"names":[],"mappings":";AA0GA;;;;;;;;;;;;;;GAcG;AACH,mCATW,OAAO,oBAAoB,EAAE,eAAe,GAC1C,OAAO,CAAC,OAAO,oBAAoB,EAAE,WAAW,CAAC,CAkB7D"} \ No newline at end of file +{"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../index.mjs"],"names":[],"mappings":";;;;;0BA6Ga,OAAO,oBAAoB,EAAE,WAAW;AAHrD;;;;GAIG;AAEH;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,yBAVuB,CAAC,SAAX,MAAQ,mEACV,OAAO,oBAAoB,EAAE,eAAe,GAC1C,OAAO,CAAC,OAAO,oBAAoB,EAAE,WAAW,GAAG,CAAC,CAAC,CAkBjE"} \ No newline at end of file diff --git a/types/src/lib/builders/modes-processor.d.mts.map b/types/src/lib/builders/modes-processor.d.mts.map index 8530f6c7c..342a9bf38 100644 --- a/types/src/lib/builders/modes-processor.d.mts.map +++ b/types/src/lib/builders/modes-processor.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"modes-processor.d.mts","sourceRoot":"","sources":["../../../../src/lib/builders/modes-processor.mjs"],"names":[],"mappings":"AA8BA;;;;;;GAMG;AAKH;IACC,gCAA2C;IAC3C;;;;OAIG;IACH,sBAFW,MAAM,EAIhB;IAmLD;;;;;;;;;;;;;;;;;;;OAmBG;IACH,kBAlBW,MAAM,SACN,KAAK,CAAC,MAAM,CAAC,aACb;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE;YAAC,KAAK,QAAQ;YAAC,WAAW,QAAO;SAAC,CAAA;KAAC,gBAC3E,MAAM,QACN,MAAM,UACN,OAAO,aACP,OAAO,qBACP,OAAO,kBACP,MAAM,qBACN,MAAM,aACN,MAAM,GAAC,IAAI,iBACX,MAAM,GAAC,IAAI,cACX,MAAM,GAAC,IAAI,0BACX,MAAM,GAAC,IAAI,eACX,OAAO,GACL,OAAO,CAAC,WAAS,IAAI,CAAC,CA2zDlC;IACD;;;;;;;;;;;;OAYG;IACH,0CAXW,MAAM,WACN,MAAM,aACN,MAAM,iBACN,MAAM,cACN,GAAC,4BACD,MAAM,kBACN,MAAM,qBACN,MAAM,oBAqahB;IACD;;;;;;;;OAQG;IACH,iCAPW,MAAM,gBACN,WAAS,IAAI,QAEb,MAAM,GACJ,OAAO,CAAC,MAAM,WAAS,CAAC,CAoBpC;;CACD;8BAl+E6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"modes-processor.d.mts","sourceRoot":"","sources":["../../../../src/lib/builders/modes-processor.mjs"],"names":[],"mappings":"AA8BA;;;;;;GAMG;AAKH;IACC,gCAA2C;IAC3C;;;;OAIG;IACH,sBAFW,MAAM,EAIhB;IAmLD;;;;;;;;;;;;;;;;;;;OAmBG;IACH,kBAlBW,MAAM,SACN,KAAK,CAAC,MAAM,CAAC,aACb;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE;YAAC,KAAK,QAAQ;YAAC,WAAW,QAAO;SAAC,CAAA;KAAC,gBAC3E,MAAM,QACN,MAAM,UACN,OAAO,aACP,OAAO,qBACP,OAAO,kBACP,MAAM,qBACN,MAAM,aACN,MAAM,GAAC,IAAI,iBACX,MAAM,GAAC,IAAI,cACX,MAAM,GAAC,IAAI,0BACX,MAAM,GAAC,IAAI,eACX,OAAO,GACL,OAAO,CAAC,WAAS,IAAI,CAAC,CA+0DlC;IACD;;;;;;;;;;;;OAYG;IACH,0CAXW,MAAM,WACN,MAAM,aACN,MAAM,iBACN,MAAM,cACN,GAAC,4BACD,MAAM,kBACN,MAAM,qBACN,MAAM,oBAuahB;IACD;;;;;;;;OAQG;IACH,iCAPW,MAAM,gBACN,WAAS,IAAI,QAEb,MAAM,GACJ,OAAO,CAAC,MAAM,WAAS,CAAC,CAoBpC;;CACD;8BAx/E6B,2BAA2B"} \ No newline at end of file diff --git a/types/src/lib/handlers/ownership.d.mts b/types/src/lib/handlers/ownership.d.mts index 53b913fd5..75d90199f 100644 --- a/types/src/lib/handlers/ownership.d.mts +++ b/types/src/lib/handlers/ownership.d.mts @@ -21,6 +21,154 @@ export class OwnershipManager extends ComponentBase { pathToModule: Map; _unregisteredModules: Set; moduleEndpoints: Map; + exportIndex: Map; + cloneSources: WeakMap; + /** + * Index where each value of a freshly loaded module sits in that module's namespace (#484). + * @param {string} filePath - Absolute path of the loaded file. + * @param {object} mod - The module namespace as loaded (`import()` result, or the CJS loader's + * `{ default: module.exports, ...ownKeys }` namespace). + * @returns {void} + * @public + * + * @description + * Wrappers built from this file later look their impl up here to learn their `exportPath` — the + * access path within the namespace that produced the value (`["add"]`, `["default"]`, + * `["default", "member"]`). A named export wins over the same value reached through `default`. + * For a CommonJS module every path is rooted at `["default"]` (`module.exports`), since the + * named keys a CommonJS namespace carries are copies of `module.exports`' own keys. + * + * @example + * ownership.indexModuleExports("/abs/api/math.mjs", await import("/abs/api/math.mjs")); + */ + public indexModuleExports(filePath: string, mod: object): void; + /** + * Whether `filePath` was loaded as a CommonJS module (its exports are `module.exports`) (#484). + * @param {string} filePath - Absolute path of a loaded file. + * @returns {boolean} True for a CommonJS module; false for ES modules and files never indexed. + * @public + * + * @example + * ownership.isCommonJSModule("/abs/api/text.cjs"); // true + */ + public isCommonJSModule(filePath: string): boolean; + /** + * Record the per-key origins of a value composed from a module's exports (#484). + * @param {string} filePath - Absolute path of the file the content was built from. + * @param {object|Function} content - The composed content (a fresh namespace object, or a default + * export the named exports were attached onto). + * @param {Object|null} members - Composed key → the exportPath it came from. + * @param {string[]|null} [self=null] - The content's own exportPath, when it is itself an export. + * @returns {void} + * @public + * + * @description + * Flattening merges a module's default and named exports into one content value, so a key's + * position under that content no longer tells where it came from. Recording the map lets a + * wrapper built from the content hand each child — including a primitive child, which has no + * identity to look up — its real exportPath. + * + * @example + * ownership.recordComposedContent(file, content, { add: ["add"], VERSION: ["VERSION"] }); + */ + public recordComposedContent(filePath: string, content: object | Function, members: { + [x: string]: string[]; + } | null, self?: string[] | null): void; + /** + * Remember that `clone` was cloned from `original`, so an origin lookup sees through the clone. + * @param {*} clone - The clone. + * @param {*} original - The value it was cloned from. + * @returns {void} + * @public + * + * @example + * ownership.noteClone(copy, exported); + */ + public noteClone(clone: any, original: any): void; + /** + * Look up where `value` sits in the module namespace of `filePath` (#484). + * @param {string|null} filePath - File the value was loaded from. + * @param {*} value - The value (or an eager-mode clone of it). + * @returns {string[]|null} The exportPath, or `null` when the value is not a known export of that file. + * @public + * + * @example + * ownership.resolveExportPath("/abs/api/math.mjs", addFn); // ["add"] + */ + public resolveExportPath(filePath: string | null, value: any): string[] | null; + /** + * Look up an export of `filePath` by the NAME it was placed under, confirmed by value (#484). + * @param {string|null} filePath - File the value was loaded from. + * @param {string} key - The key the value sits under in the composed api. + * @param {*} value - The value itself; must be the value that export held (`Object.is`). + * @returns {string[]|null} The exportPath, or `null` when `filePath` has no export of that name + * holding that value. + * @public + * + * @description + * For a value with no identity to look up — a primitive — merged into a namespace that several + * files compose (a folder whose files flatten into it), where no per-key map was recorded for the + * namespace itself. The value check keeps a same-named but different value from borrowing the path. + * + * @example + * ownership.resolveExportPathByKey("/abs/api/tuning/tuning.mjs", "TUNE_STEP", 5); // ["TUNE_STEP"] + */ + public resolveExportPathByKey(filePath: string | null, key: string, value: any): string[] | null; + /** + * Look up the per-key origins recorded for a composed content value (#484). + * @param {string|null} filePath - File the content was built from. + * @param {*} value - The content (or an eager-mode clone of it). + * @returns {Object|null} Composed key → exportPath, or `null`. + * @public + * + * @example + * ownership.resolveMemberExportPaths(file, content); // { add: ["add"] } + */ + public resolveMemberExportPaths(filePath: string | null, value: any): { + [x: string]: string[]; + } | null; + /** + * Read where the value currently at `apiPath` came from (#484). + * @param {string} apiPath - API path to look up. + * @returns {{moduleID: string, filePath: (string|null), exportPath: (string[]|null), members: (Object|null)}|null} + * The current owner's origin, or `null` when nothing owns the path. + * @public + * + * @description + * `exportPath` is the access path within `filePath`'s module namespace that produced the value; + * `null` when the value has no module origin (a runtime `self.X = value`, a synthetic in-memory + * `api.add()`, a namespace container slothlet created). `members` carries per-key origins for a + * value composed from several exports. + * + * @example + * ownership.getOrigin("math.add"); // { moduleID, filePath: "/abs/api/math.mjs", exportPath: ["add"], members: null } + */ + public getOrigin(apiPath: string): { + moduleID: string; + filePath: (string | null); + exportPath: (string[] | null); + members: ({ + [x: string]: string[]; + } | null); + } | null; + /** + * Refresh the origin recorded for a module's entry at `apiPath` without touching its position, + * source, or collision state — for a wrapper whose impl arrived after it was registered (lazy + * materialization, hot reload). + * @param {string} moduleID - Module whose entry to refresh. + * @param {string} apiPath - API path of the entry. + * @param {{filePath?: (string|null), exportPath?: (string[]|null), memberExportPaths?: (Object|null)}} origin - New origin. + * @returns {void} + * @public + * + * @example + * ownership.refreshOrigin("base", "math.add", { filePath, exportPath: ["add"] }); + */ + public refreshOrigin(moduleID: string, apiPath: string, origin: { + filePath?: (string | null); + exportPath?: (string[] | null); + memberExportPaths?: (Object | null); + }): void; /** * 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 @@ -48,10 +196,15 @@ export class OwnershipManager extends ComponentBase { * @param {string} [options.collisionMode="error"] - Collision mode: skip, warn, error, merge, replace * @param {Object} [options.config] - Config object for silent mode check * @param {string} [options.filePath=null] - File path of the module source (for metadata tracking) + * @param {string[]|null} [options.exportPath=null] - Access path within `filePath`'s module namespace + * that produced `value` (`["add"]`, `["default"]`, `["default", "member"]`); `null` when the value + * has no module origin (#484). + * @param {Object|null} [options.memberExportPaths=null] - Per-key exportPaths for a + * value composed from several exports (#484). * @returns {Object|null} Registration entry or null if skipped * @public */ - public register({ moduleID, apiPath, value, source, collisionMode, config, filePath }: { + public register({ moduleID, apiPath, value, source, collisionMode, config, filePath, exportPath, memberExportPaths }: { moduleID: string; apiPath: string; value: any; @@ -59,6 +212,10 @@ export class OwnershipManager extends ComponentBase { collisionMode?: string | undefined; config?: Object | undefined; filePath?: string | undefined; + exportPath?: string[] | null | undefined; + memberExportPaths?: { + [x: string]: string[]; + } | null | undefined; }): Object | null; /** * @param {string} moduleID - Module to unregister. @@ -209,7 +366,7 @@ export class OwnershipManager extends ComponentBase { /** * Snapshot the entries moduleID currently owns, keyed by apiPath, for later restoration * @param {string} moduleID - Module identifier to snapshot. - * @returns {Map} + * @returns {Map} * One entry per apiPath the module currently owns, capturing exactly the fields a duplicate * registration can overwrite. * @public @@ -225,6 +382,8 @@ export class OwnershipManager extends ComponentBase { public snapshotModuleEntries(moduleID: string): Map; @@ -233,7 +392,7 @@ export class OwnershipManager extends ComponentBase { * overwrite without changing its position in the ownership stack * @param {string} moduleID - Module identifier. * @param {string} apiPath - API path whose entry to restore. - * @param {{value: *, filePath: (string|null), source: string, isMergeLoss: boolean}} snapshot - + * @param {{value: *, filePath: (string|null), exportPath?: (string[]|null), memberExportPaths?: (Object|null), source: string, isMergeLoss: boolean}} snapshot - * Prior field values, from {@link OwnershipManager#snapshotModuleEntries}. * @returns {void} * @public @@ -244,6 +403,8 @@ export class OwnershipManager extends ComponentBase { public restoreEntry(moduleID: string, apiPath: string, snapshot: { value: any; filePath: (string | null); + exportPath?: (string[] | null); + memberExportPaths?: (Object | null); source: string; isMergeLoss: boolean; }): void; @@ -252,7 +413,7 @@ export class OwnershipManager extends ComponentBase { * {@link OwnershipManager#snapshotModuleEntries}, for an internal candidate's own revert. * @param {string} apiPath - Full api path the candidate is about to (re-)contribute to. * @param {string} moduleID - Module identifier making the contribution. - * @returns {{value: *, filePath: (string|null), source: string, isMergeLoss: boolean}|undefined} + * @returns {{value: *, filePath: (string|null), exportPath: (string[]|null), memberExportPaths: (Object|null), source: string, isMergeLoss: boolean}|undefined} * The prior entry's snapshot, or `undefined` if none exists yet. * @public * @@ -279,6 +440,8 @@ export class OwnershipManager extends ComponentBase { public snapshotPathEntry(apiPath: string, moduleID: string): { value: any; filePath: (string | null); + exportPath: (string[] | null); + memberExportPaths: (Object | null); source: string; isMergeLoss: boolean; } | undefined; diff --git a/types/src/lib/handlers/ownership.d.mts.map b/types/src/lib/handlers/ownership.d.mts.map index 53967c2f7..4e2066e76 100644 --- a/types/src/lib/handlers/ownership.d.mts.map +++ b/types/src/lib/handlers/ownership.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"ownership.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/ownership.mjs"],"names":[],"mappings":"AAsGA;;;;;GAKG;AAEH;;;;;GAKG;AACH;IACC,gCAAsC;IAEtC;;;OAGG;IACH,sBAFW,MAAM,EAQhB;IAJA,4BAA6B;IAC7B,4BAA6B;IAC7B,+BAAqC;IACrC,+BAAgC;IAGjC;;;;;;;;OAQG;IACH,mCALW,MAAM,YACN,MAAM,GACJ,IAAI,CAUhB;IAED;;;;;OAKG;IACH,mCAJW,MAAM,GACJ,MAAM,GAAC,SAAS,CAK5B;IAED;;;;;;;;;;;;OAYG;IACH,uFAVG;QAAwB,QAAQ,EAAxB,MAAM;QACU,OAAO,EAAvB,MAAM;QACK,KAAK,EAAhB,GAAC;QACgB,MAAM;QACN,aAAa;QACb,MAAM;QACN,QAAQ;KACjC,GAAU,MAAM,GAAC,IAAI,CAmMvB;IA2BD;;;;;;;;;;OAUG;IACH,4BAVW,MAAM,GACJ,gBAAgB,CAuC5B;IAED;;;;;;;;;;;;;;;;;;;;;;;;OAwBG;IACH,kCAtBW,MAAM,GACJ,IAAI,CAwBhB;IAED;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,mCApBW,MAAM,GACJ,IAAI,CAqBhB;IAED;;;;;;;;;;;;;OAaG;IACH,2BAbW,MAAM,aACN,MAAM,GAAC,IAAI,GACT;QAAE,MAAM,EAAE,QAAQ,GAAC,MAAM,GAAC,SAAS,CAAC;QAAC,eAAe,EAAE,MAAM,GAAC,IAAI,CAAC;QAC5E,eAAe,EAAE,MAAM,GAAC,IAAI,CAAA;KAAE,CAgDhC;IAED;;;;;OAKG;IACH,gCAJW,MAAM,GACJ,MAAM,GAAC,IAAI,CAevB;IAED;;;;;OAKG;IACH,gCAJW,MAAM,GACJ,GAAC,CAkBb;IAED;;;;;OAKG;IACH,gCAJW,MAAM,GACJ,KAAK,CAAC,MAAM,CAAC,CAKzB;IAED;;;;;OAKG;IACH,+BAJW,MAAM,GACJ,KAAK,CAAC,MAAM,CAAC,CAKzB;IAED;;;;;;OAMG;IACH,0BALW,MAAM,WACN,MAAM,GACJ,OAAO,CAMnB;IAED;;;;OAIG;IACH,yBAHa,MAAM,CAkBlB;IAED;;;;;OAKG;IACH,iCAJW,MAAM,GACJ,GAAG,CAAC,MAAM,CAAC,GAAC,IAAI,CAS5B;IAED;;;;;;;;;;;;;;;OAeG;IACH,4BAdW,MAAM,YACN,MAAM,QACN,MAAM,2BAEJ,IAAI,CA+DhB;IAED;;;;;;;;;;;;;;;OAeG;IACH,uCAdW,MAAM,GACJ,GAAG,CAAC,MAAM,EAAE;QAAC,KAAK,EAAE,GAAC,CAAC;QAAC,QAAQ,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,OAAO,CAAA;KAAC,CAAC,CA0BlG;IAED;;;;;;;;;;;;OAYG;IACH,8BAVW,MAAM,WACN,MAAM,YACN;QAAC,KAAK,EAAE,GAAC,CAAC;QAAC,QAAQ,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,OAAO,CAAA;KAAC,GAEvE,IAAI,CAahB;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,kCA1BW,MAAM,YACN,MAAM,GACJ;QAAC,KAAK,EAAE,GAAC,CAAC;QAAC,QAAQ,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,OAAO,CAAA;KAAC,GAAC,SAAS,CA4B/F;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;IACH,qCA5BW,MAAM,YACN,MAAM,QACN,MAAM,gBACN,GAAG,CAAC,MAAM,EAAE;QAAC,KAAK,EAAE,GAAC,CAAC;QAAC,QAAQ,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAC,CAAC,2BAI9D,IAAI,CAmEhB;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;IACH,wCA3BW,MAAM,gBACN,GAAG,CAAC,MAAM,EAAE;QAAC,KAAK,EAAE,GAAC,CAAC;QAAC,QAAQ,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,OAAO,CAAA;KAAC,CAAC,GAGpF,IAAI,CAgChB;IAED;;;OAGG;IACH,qBAKC;IAED;;;;OAIG;IACH,sBAHa,MAAM,CAQlB;IAED;;;;OAIG;IACH,0BAHW,MAAM,QAgBhB;;CACD;;;;;;;;aAr2Ba,MAAM,EAAE;;;;gBACR,MAAM,EAAE;;8BAxFQ,2BAA2B"} \ No newline at end of file +{"version":3,"file":"ownership.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/ownership.mjs"],"names":[],"mappings":"AAyLA;;;;;GAKG;AAEH;;;;;GAKG;AACH;IACC,gCAAsC;IAEtC;;;OAGG;IACH,sBAFW,MAAM,EAahB;IATA,4BAA6B;IAC7B,4BAA6B;IAC7B,+BAAqC;IACrC,+BAAgC;IAGhC,2BAA4B;IAE5B,mCAAiC;IAGlC;;;;;;;;;;;;;;;;;OAiBG;IACH,oCAhBW,MAAM,OACN,MAAM,GAEJ,IAAI,CA6DhB;IAED;;;;;;;;OAQG;IACH,kCAPW,MAAM,GACJ,OAAO,CAQnB;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,uCAjBW,MAAM,WACN,MAAM,WAAS,WAEf;YAAO,MAAM,GAAE,MAAM,EAAE;KAAC,GAAC,IAAI,SAC7B,MAAM,EAAE,GAAC,IAAI,GACX,IAAI,CAoBhB;IAED;;;;;;;;;OASG;IACH,wBARW,GAAC,YACD,GAAC,GACC,IAAI,CAQhB;IAED;;;;;;;;;OASG;IACH,mCARW,MAAM,GAAC,IAAI,SACX,GAAC,GACC,MAAM,EAAE,GAAC,IAAI,CASzB;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,wCAfW,MAAM,GAAC,IAAI,OACX,MAAM,SACN,GAAC,GACC,MAAM,EAAE,GAAC,IAAI,CAgBzB;IAED;;;;;;;;;OASG;IACH,0CARW,MAAM,GAAC,IAAI,SACX,GAAC,GACC;YAAO,MAAM,GAAE,MAAM,EAAE;KAAC,GAAC,IAAI,CAQzC;IAyBD;;;;;;;;;;;;;;;OAeG;IACH,0BAdW,MAAM,GACJ;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;QAAC,UAAU,EAAE,CAAC,MAAM,EAAE,GAAC,IAAI,CAAC,CAAC;QAAC,OAAO,EAAE,CAAC;gBAAO,MAAM,GAAE,MAAM,EAAE;SAAC,GAAC,IAAI,CAAC,CAAA;KAAC,GAAC,IAAI,CAsBnI;IAED;;;;;;;;;;;;OAYG;IACH,+BATW,MAAM,WACN,MAAM,UACN;QAAC,QAAQ,CAAC,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;QAAC,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,GAAC,IAAI,CAAC,CAAC;QAAC,iBAAiB,CAAC,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAA;KAAC,GACzF,IAAI,CAYhB;IAED;;;;;;;;OAQG;IACH,mCALW,MAAM,YACN,MAAM,GACJ,IAAI,CAUhB;IAED;;;;;OAKG;IACH,mCAJW,MAAM,GACJ,MAAM,GAAC,SAAS,CAK5B;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,sHAfG;QAAwB,QAAQ,EAAxB,MAAM;QACU,OAAO,EAAvB,MAAM;QACK,KAAK,EAAhB,GAAC;QACgB,MAAM;QACN,aAAa;QACb,MAAM;QACN,QAAQ;QACD,UAAU;QAGM,iBAAiB;;;KAEjE,GAAU,MAAM,GAAC,IAAI,CA0NvB;IA2BD;;;;;;;;;;OAUG;IACH,4BAVW,MAAM,GACJ,gBAAgB,CAuC5B;IAED;;;;;;;;;;;;;;;;;;;;;;;;OAwBG;IACH,kCAtBW,MAAM,GACJ,IAAI,CAwBhB;IAED;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,mCApBW,MAAM,GACJ,IAAI,CAqBhB;IAED;;;;;;;;;;;;;OAaG;IACH,2BAbW,MAAM,aACN,MAAM,GAAC,IAAI,GACT;QAAE,MAAM,EAAE,QAAQ,GAAC,MAAM,GAAC,SAAS,CAAC;QAAC,eAAe,EAAE,MAAM,GAAC,IAAI,CAAC;QAC5E,eAAe,EAAE,MAAM,GAAC,IAAI,CAAA;KAAE,CAgDhC;IAED;;;;;OAKG;IACH,gCAJW,MAAM,GACJ,MAAM,GAAC,IAAI,CAevB;IAED;;;;;OAKG;IACH,gCAJW,MAAM,GACJ,GAAC,CAkBb;IAED;;;;;OAKG;IACH,gCAJW,MAAM,GACJ,KAAK,CAAC,MAAM,CAAC,CAKzB;IAED;;;;;OAKG;IACH,+BAJW,MAAM,GACJ,KAAK,CAAC,MAAM,CAAC,CAKzB;IAED;;;;;;OAMG;IACH,0BALW,MAAM,WACN,MAAM,GACJ,OAAO,CAMnB;IAED;;;;OAIG;IACH,yBAHa,MAAM,CAkBlB;IAED;;;;;OAKG;IACH,iCAJW,MAAM,GACJ,GAAG,CAAC,MAAM,CAAC,GAAC,IAAI,CAS5B;IAED;;;;;;;;;;;;;;;OAeG;IACH,4BAdW,MAAM,YACN,MAAM,QACN,MAAM,2BAEJ,IAAI,CA+DhB;IAED;;;;;;;;;;;;;;;OAeG;IACH,uCAdW,MAAM,GACJ,GAAG,CAAC,MAAM,EAAE;QAAC,KAAK,EAAE,GAAC,CAAC;QAAC,QAAQ,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;QAAC,UAAU,EAAE,CAAC,MAAM,EAAE,GAAC,IAAI,CAAC,CAAC;QAAC,iBAAiB,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,OAAO,CAAA;KAAC,CAAC,CAiCjK;IAED;;;;;;;;;;;;OAYG;IACH,8BAVW,MAAM,WACN,MAAM,YACN;QAAC,KAAK,EAAE,GAAC,CAAC;QAAC,QAAQ,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;QAAC,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,GAAC,IAAI,CAAC,CAAC;QAAC,iBAAiB,CAAC,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,OAAO,CAAA;KAAC,GAExI,IAAI,CAehB;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA4BG;IACH,kCA1BW,MAAM,YACN,MAAM,GACJ;QAAC,KAAK,EAAE,GAAC,CAAC;QAAC,QAAQ,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;QAAC,UAAU,EAAE,CAAC,MAAM,EAAE,GAAC,IAAI,CAAC,CAAC;QAAC,iBAAiB,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,OAAO,CAAA;KAAC,GAAC,SAAS,CAmC9J;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;IACH,qCA5BW,MAAM,YACN,MAAM,QACN,MAAM,gBACN,GAAG,CAAC,MAAM,EAAE;QAAC,KAAK,EAAE,GAAC,CAAC;QAAC,QAAQ,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAC,CAAC,2BAI9D,IAAI,CAmEhB;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;IACH,wCA3BW,MAAM,gBACN,GAAG,CAAC,MAAM,EAAE;QAAC,KAAK,EAAE,GAAC,CAAC;QAAC,QAAQ,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,OAAO,CAAA;KAAC,CAAC,GAGpF,IAAI,CAgChB;IAED;;;OAGG;IACH,qBAMC;IAED;;;;OAIG;IACH,sBAHa,MAAM,CAQlB;IAED;;;;OAIG;IACH,0BAHW,MAAM,QAgBhB;;CACD;;;;;;;;aA9oCa,MAAM,EAAE;;;;gBACR,MAAM,EAAE;;8BA3KQ,2BAA2B"} \ No newline at end of file diff --git a/types/src/lib/handlers/unified-wrapper.d.mts b/types/src/lib/handlers/unified-wrapper.d.mts index 44de09ab7..8acf98b9b 100644 --- a/types/src/lib/handlers/unified-wrapper.d.mts +++ b/types/src/lib/handlers/unified-wrapper.d.mts @@ -105,6 +105,9 @@ export class UnifiedWrapper extends ComponentBase { * @param {boolean} [options.isCallable=false] - Whether the wrapper should be callable * @param {boolean} [options.materializeOnCreate=false] - Whether to materialize on creation * @param {string} [options.filePath=null] - File path of the module source + * @param {string[]|null} [options.exportPath] - Access path within `filePath`'s module namespace + * that produced `initialImpl` (#484). When omitted it is looked up from the ownership export index + * by `initialImpl`'s identity; `null` records "no module origin". * @param {string} [options.moduleID=null] - Module identifier * @param {string} [options.sourceFolder=null] - Source folder for metadata * @param {WeakSet|null} [options.__adoptVisited=null] - Internal: one-shot cycle-guard set @@ -129,7 +132,7 @@ export class UnifiedWrapper extends ComponentBase { * materializeFunc: async () => import("./math.mjs") * }); */ - constructor(slothlet: Object, { mode, apiPath, initialImpl, materializeFunc, isCallable, materializeOnCreate, filePath, moduleID, sourceFolder, __adoptVisited, deferChildAdopt }: { + constructor(slothlet: Object, { mode, apiPath, initialImpl, materializeFunc, isCallable, materializeOnCreate, filePath, exportPath, moduleID, sourceFolder, __adoptVisited, deferChildAdopt }: { mode: string; apiPath: string; initialImpl?: Object | Function | null | undefined; @@ -137,6 +140,7 @@ export class UnifiedWrapper extends ComponentBase { isCallable?: boolean | undefined; materializeOnCreate?: boolean | undefined; filePath?: string | undefined; + exportPath?: string[] | null | undefined; moduleID?: string | undefined; sourceFolder?: string | undefined; __adoptVisited?: WeakSet | null | undefined; diff --git a/types/src/lib/handlers/unified-wrapper.d.mts.map b/types/src/lib/handlers/unified-wrapper.d.mts.map index 26ceee82a..b608810a1 100644 --- a/types/src/lib/handlers/unified-wrapper.d.mts.map +++ b/types/src/lib/handlers/unified-wrapper.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"unified-wrapper.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/unified-wrapper.mjs"],"names":[],"mappings":"AA0xBA;;;;;;;;;;;;;;;;;;GAkBG;AACH,4CAhBW,MAAM,GAAC,MAAM,GACX,OAAO,CAkBnB;AAmyID;;;;;;;;;;;GAWG;AACH,sCAPW,OAAO,GACL,cAAc,GAAC,IAAI,CAkB/B;AA51ID;;;;;;;;GAQG;AACH,iCAHU,GAAG,CAAC,MAAM,CAAC,CAG2F;;;;;AAuEhH;;;;;;;;;;GAUG;AACH;IAiTC;;;;;;;;;;;;;;;;OAgBG;IACH,0BAsCC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,gCAiEC;IAnaD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAiCG;IACH,sBAjCW,MAAM,uJAEd;QAAwB,IAAI,EAApB,MAAM;QACU,OAAO,EAAvB,MAAM;QACyB,WAAW;QACvB,eAAe;QAChB,UAAU;QACV,mBAAmB;QACpB,QAAQ;QACR,QAAQ;QACR,YAAY;QACE,cAAc;QAI3B,eAAe;KAMzC,EA4MF;IAnPD;;;;;;;;;;;OAWG;IACH,4BAFa,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAC,SAAS,CAKzC;IAsOD;;;;;;;;;;OAUG;IACH,uEAHa,GAAC,CA+Bb;IAED;;;;OAIG;IACH,qBAHa,WAAS,MAAM,GAAC,IAAI,CAKhC;IAiJD;;;;;;;;;;;OAWG;IACH,sBAiCC;IAED;;;;;;;;;;;;OAYG;IACH,mBAgDC;IAED;;;;;;;;;OASG;IACH,qBAsDC;IAED;;;;OAIG;IACH,uBA4GC;IAED;;;;;;;;;OASG;IACH,qBAEC;IAED;;;;;;;;;OASG;IACH,sBAiBC;IAED;;;;;;;;;;OAUG;IACH,6BA4gBC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BAgOC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BA43BC;IAED;;;;;;OAMG;IACH,uCAuyDC;;CACD;8BAxiK6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"unified-wrapper.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/unified-wrapper.mjs"],"names":[],"mappings":"AA0xBA;;;;;;;;;;;;;;;;;;GAkBG;AACH,4CAhBW,MAAM,GAAC,MAAM,GACX,OAAO,CAkBnB;AAi0ID;;;;;;;;;;;GAWG;AACH,sCAPW,OAAO,GACL,cAAc,GAAC,IAAI,CAkB/B;AA13ID;;;;;;;;GAQG;AACH,iCAHU,GAAG,CAAC,MAAM,CAAC,CAG2F;;;;;AAuEhH;;;;;;;;;;GAUG;AACH;IAuUC;;;;;;;;;;;;;;;;OAgBG;IACH,0BAsCC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,gCAiEC;IAzbD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAoCG;IACH,sBApCW,MAAM,mKAEd;QAAwB,IAAI,EAApB,MAAM;QACU,OAAO,EAAvB,MAAM;QACyB,WAAW;QACvB,eAAe;QAChB,UAAU;QACV,mBAAmB;QACpB,QAAQ;QACD,UAAU;QAGjB,QAAQ;QACR,YAAY;QACE,cAAc;QAI3B,eAAe;KAMzC,EA+NF;IAzQD;;;;;;;;;;;OAWG;IACH,4BAFa,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAC,SAAS,CAKzC;IA4PD;;;;;;;;;;OAUG;IACH,uEAHa,GAAC,CA+Bb;IAED;;;;OAIG;IACH,qBAHa,WAAS,MAAM,GAAC,IAAI,CAKhC;IAiJD;;;;;;;;;;;OAWG;IACH,sBA0CC;IAED;;;;;;;;;;;;OAYG;IACH,mBAkDC;IAED;;;;;;;;;OASG;IACH,qBAsDC;IAED;;;;OAIG;IACH,uBA4GC;IAED;;;;;;;;;OASG;IACH,qBAEC;IAED;;;;;;;;;OASG;IACH,sBAiBC;IAED;;;;;;;;;;OAUG;IACH,6BA4gBC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BA8PC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BA43BC;IAED;;;;;;OAMG;IACH,uCAswDC;;CACD;8BAtkK6B,2BAA2B"} \ No newline at end of file diff --git a/types/src/lib/helpers/modes-utils.d.mts.map b/types/src/lib/helpers/modes-utils.d.mts.map index d3fe27b81..2bf409511 100644 --- a/types/src/lib/helpers/modes-utils.d.mts.map +++ b/types/src/lib/helpers/modes-utils.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"modes-utils.d.mts","sourceRoot":"","sources":["../../../../src/lib/helpers/modes-utils.mjs"],"names":[],"mappings":"AAqBA;;;GAGG;AACH;IACC,gCAAuC;IAEvC;;;;;;;;OAQG;IACH,4EAIC;IAED;;;;;;OAMG;IACH,+BALW,OAAO,QACP,MAAM,GACJ,OAAO,CAenB;IAED;;;;;;OAMG;IACH,yCALW,MAAM,qBACN,MAAM,GACJ,MAAM,CAKlB;CACD;8BAvD6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"modes-utils.d.mts","sourceRoot":"","sources":["../../../../src/lib/helpers/modes-utils.mjs"],"names":[],"mappings":"AAqBA;;;GAGG;AACH;IACC,gCAAuC;IAEvC;;;;;;;;OAQG;IACH,4EAIC;IAED;;;;;;OAMG;IACH,+BALW,OAAO,QACP,MAAM,GACJ,OAAO,CAgBnB;IAED;;;;;;OAMG;IACH,yCALW,MAAM,qBACN,MAAM,GACJ,MAAM,CAKlB;CACD;8BAxD6B,2BAA2B"} \ No newline at end of file diff --git a/types/src/lib/processors/flatten.d.mts b/types/src/lib/processors/flatten.d.mts index 92cef5f05..c85e337fb 100644 --- a/types/src/lib/processors/flatten.d.mts +++ b/types/src/lib/processors/flatten.d.mts @@ -59,7 +59,12 @@ export class Flatten extends ComponentBase { * @param {string|null} [options.collisionModeOverride=null] - Caller's per-call override (e.g. * `api.add({ forceOverwrite: true })`), preferred over `collisionContext`'s config default for * the function-default-vs-named-export merge decision below. - * @returns {{ moduleContent: object|Function }} Built module content ready for wrapping/assignment. + * @returns {{ moduleContent: object|Function, origins: {self: (string[]|null), members: Object} }} + * Built module content ready for wrapping/assignment, plus where it came from (#484): `self` is the + * content's own exportPath when it IS one export (`["default"]`, `["add"]`), `null` when it is a + * fresh object composed here; `members` maps each key this step placed onto the content to the + * exportPath it was read from. Keys the content already carried (a default object's own members) + * are not listed — they are located under `self`. * @public */ public processModuleForAPI(options: { @@ -75,6 +80,12 @@ export class Flatten extends ComponentBase { collisionModeOverride?: string | null | undefined; }): { moduleContent: object | Function; + origins: { + self: (string[] | null); + members: { + [x: string]: string[]; + }; + }; }; /** * Build category-level flattening decisions. diff --git a/types/src/lib/processors/flatten.d.mts.map b/types/src/lib/processors/flatten.d.mts.map index 3d1982758..fb5f44bc2 100644 --- a/types/src/lib/processors/flatten.d.mts.map +++ b/types/src/lib/processors/flatten.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"flatten.d.mts","sourceRoot":"","sources":["../../../../src/lib/processors/flatten.mjs"],"names":[],"mappings":"AA8BA;;;;;GAKG;AAMH;IACC,gCAAoC;IAEpC;;;OAGG;IACH,sBAFW,MAAM,EAIhB;IA4DD;;;;;;;;;;;;;OAaG;IACH,sCAVG;QAAwB,GAAG,EAAnB,MAAM;QACU,UAAU,EAA1B,MAAM;QACU,YAAY,EAA5B,MAAM;QACU,QAAQ,EAAxB,MAAM;QACW,mBAAmB,EAApC,OAAO;QACW,UAAU,EAA5B,MAAM,EAAE;QACU,CAAC;KAC3B,GAAU,OAAO,CAAC,MAAM,CAAC,CAiG3B;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;OA2BG;IACH,oCAfG;QAA0B,GAAG,EAArB,MAAM;QACY,QAAQ,EAA1B,MAAM;QACY,UAAU,EAA5B,MAAM;QACY,YAAY,EAA9B,MAAM;QACY,UAAU,EAA5B,MAAM,EAAE;QACU,QAAQ,EAA1B,MAAM;QACa,IAAI;QACJ,gBAAgB;QAChB,aAAa;QACV,qBAAqB;KAGnD,GAAU;QAAE,aAAa,EAAE,MAAM,WAAS,CAAA;KAAE,CAgK9C;IAED;;;;;;;;;;;;;;;OAeG;IACH,uCAZG;QAAwB,YAAY,EAA5B,MAAM;QACU,GAAG,EAAnB,MAAM;QACU,UAAU,EAA1B,MAAM;QACU,YAAY,EAA5B,MAAM;QACU,QAAQ,EAAxB,MAAM;QACY,UAAU,EAA5B,MAAM,EAAE;QACQ,YAAY,EAA5B,MAAM;QACa,WAAW,EAA9B,OAAO,EAAE;QACS,CAAC;KAC3B,GAAU,OAAO,CAAC,MAAM,CAAC,CAiJ3B;IAED;;;;;;;;;;;;OAYG;IACH,oCAPW,MAAM,SACN,OAAO,qDAGL,OAAO,CAiBnB;;CACD;8BAvjB6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"flatten.d.mts","sourceRoot":"","sources":["../../../../src/lib/processors/flatten.mjs"],"names":[],"mappings":"AA8BA;;;;;GAKG;AAMH;IACC,gCAAoC;IAEpC;;;OAGG;IACH,sBAFW,MAAM,EAIhB;IA4DD;;;;;;;;;;;;;OAaG;IACH,sCAVG;QAAwB,GAAG,EAAnB,MAAM;QACU,UAAU,EAA1B,MAAM;QACU,YAAY,EAA5B,MAAM;QACU,QAAQ,EAAxB,MAAM;QACW,mBAAmB,EAApC,OAAO;QACW,UAAU,EAA5B,MAAM,EAAE;QACU,CAAC;KAC3B,GAAU,OAAO,CAAC,MAAM,CAAC,CAiG3B;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAgCG;IACH,oCApBG;QAA0B,GAAG,EAArB,MAAM;QACY,QAAQ,EAA1B,MAAM;QACY,UAAU,EAA5B,MAAM;QACY,YAAY,EAA9B,MAAM;QACY,UAAU,EAA5B,MAAM,EAAE;QACU,QAAQ,EAA1B,MAAM;QACa,IAAI;QACJ,gBAAgB;QAChB,aAAa;QACV,qBAAqB;KAGnD,GAAU;QAAE,aAAa,EAAE,MAAM,WAAS,CAAC;QAAC,OAAO,EAAE;YAAC,IAAI,EAAE,CAAC,MAAM,EAAE,GAAC,IAAI,CAAC,CAAC;YAAC,OAAO,EAAE;oBAAO,MAAM,GAAE,MAAM,EAAE;aAAC,CAAA;SAAC,CAAA;KAAE,CA8LnH;IAED;;;;;;;;;;;;;;;OAeG;IACH,uCAZG;QAAwB,YAAY,EAA5B,MAAM;QACU,GAAG,EAAnB,MAAM;QACU,UAAU,EAA1B,MAAM;QACU,YAAY,EAA5B,MAAM;QACU,QAAQ,EAAxB,MAAM;QACY,UAAU,EAA5B,MAAM,EAAE;QACQ,YAAY,EAA5B,MAAM;QACa,WAAW,EAA9B,OAAO,EAAE;QACS,CAAC;KAC3B,GAAU,OAAO,CAAC,MAAM,CAAC,CAiJ3B;IAED;;;;;;;;;;;;OAYG;IACH,oCAPW,MAAM,SACN,OAAO,qDAGL,OAAO,CAiBnB;;CACD;8BArlB6B,2BAA2B"} \ No newline at end of file diff --git a/types/src/lib/processors/type-generator.d.mts b/types/src/lib/processors/type-generator.d.mts index 67e8ad66b..b57839cd1 100644 --- a/types/src/lib/processors/type-generator.d.mts +++ b/types/src/lib/processors/type-generator.d.mts @@ -1,17 +1,30 @@ /** * Generate TypeScript declaration file for a Slothlet API - * @param {object} api - The loaded Slothlet API + * @param {object} api - The loaded Slothlet API (a live, composed instance) * @param {object} options - Generation options * @param {string} options.output - Output file path for .d.ts * @param {string} options.interfaceName - Name of the interface to generate - * @param {boolean} [options.includeDocumentation=true] - Include JSDoc comments + * @param {boolean} [options.includeDocumentation=true] - Include the per-member comments that explain an + * `unknown` member (a member with no module origin). Accepted for compatibility; the explanation is + * always emitted, since an unexplained `unknown` is a worse surprise than one extra comment line. + * @param {boolean} [options.augmentRuntime=true] - Extend `SlothletSelf` from `@cldmv/slothlet/runtime` + * with the generated interface, so an imported `self` is typed as the api. Turn it off when several + * generated interfaces live in one TypeScript program and only one of them should type `self`. * @returns {Promise<{output: string, filePath: string}>} Generated declaration and output path * @public + * + * @description + * Every member references the export slothlet actually placed there — `typeof import("")["k"]` — + * read from the ownership records' module origin (#484), so the declaration carries each leaf's own + * type (JSDoc, TypeScript, CommonJS alike) instead of a signature guessed from its file's syntax. A + * namespace becomes a nested object of its members; a callable namespace intersects its function's + * type with the members merged onto it; a member with no module origin is `unknown`, with a comment. */ export function generateTypes(api: object, options: { output: string; interfaceName: string; includeDocumentation?: boolean | undefined; + augmentRuntime?: boolean | undefined; }): Promise<{ output: string; filePath: string; diff --git a/types/src/lib/processors/type-generator.d.mts.map b/types/src/lib/processors/type-generator.d.mts.map index b5f93faf8..923f41738 100644 --- a/types/src/lib/processors/type-generator.d.mts.map +++ b/types/src/lib/processors/type-generator.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"type-generator.d.mts","sourceRoot":"","sources":["../../../../src/lib/processors/type-generator.mjs"],"names":[],"mappings":"AA8CA;;;;;;;;;GASG;AACH,mCARW,MAAM,WAEd;IAAwB,MAAM,EAAtB,MAAM;IACU,aAAa,EAA7B,MAAM;IACY,oBAAoB;CAC9C,GAAU,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAC,CAAC,CA6DvD"} \ No newline at end of file +{"version":3,"file":"type-generator.d.mts","sourceRoot":"","sources":["../../../../src/lib/processors/type-generator.mjs"],"names":[],"mappings":"AAsCA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,mCApBW,MAAM,WAEd;IAAwB,MAAM,EAAtB,MAAM;IACU,aAAa,EAA7B,MAAM;IACY,oBAAoB;IAGpB,cAAc;CAGxC,GAAU,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAC,CAAC,CAuDvD"} \ No newline at end of file diff --git a/types/src/lib/runtime/runtime.d.mts b/types/src/lib/runtime/runtime.d.mts index 22394a044..f0f131843 100644 --- a/types/src/lib/runtime/runtime.d.mts +++ b/types/src/lib/runtime/runtime.d.mts @@ -1,15 +1,36 @@ +/** + * Type anchor for {@link self}. It has no runtime behavior and is never instantiated. + * + * `self` is typed as `SlothletSelf`, and TypeScript merges an interface of the same name into a + * class, so a project types `self` for its whole api by extending this class from a module + * augmentation. `slothlet typegen` emits exactly that: + * ```ts + * declare module "@cldmv/slothlet/runtime" { + * interface SlothletSelf extends MyApi {} + * } + * ``` + * + * @memberof module:@cldmv/slothlet/runtime + * @example + * // api.d.ts (generated by `slothlet typegen -n MyApi`) extends SlothletSelf, so in any leaf: + * import { self } from "@cldmv/slothlet/runtime"; + * await self.math.add(1, 2); // typed from MyApi + */ +export class SlothletSelf { +} /** * Live binding to the current API instance. Resolves to the running Slothlet proxy, * giving API modules access to all other API methods without import cycles. + * Typed as {@link SlothletSelf}, which generated api types extend. * * @memberof module:@cldmv/slothlet/runtime - * @type {object} + * @type {SlothletSelf} * @example * import { self } from "@cldmv/slothlet/runtime"; * // Inside an API function: * const result = await self.math.add(1, 2); */ -export const self: object; +export const self: SlothletSelf; /** * The current ambient context object. Seeded at instance startup via `config.context` and * persists for the lifetime of the instance. `api.slothlet.context.run()` and `.scope()` can diff --git a/types/src/lib/runtime/runtime.d.mts.map b/types/src/lib/runtime/runtime.d.mts.map index 2eaaadad9..39199aefe 100644 --- a/types/src/lib/runtime/runtime.d.mts.map +++ b/types/src/lib/runtime/runtime.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"runtime.d.mts","sourceRoot":"","sources":["../../../../src/lib/runtime/runtime.mjs"],"names":[],"mappings":"AAkEA;;;;;;;;;;GAUG;AACH,mBANU,MAAM,CAwCd;AAEF;;;;;;;;;;;;;;GAcG;AACH,sBARU,MAAM,CAiCd;AAEF;;;;;;;;;GASG;AACH,yBALU,MAAM,CAiBd"} \ No newline at end of file +{"version":3,"file":"runtime.d.mts","sourceRoot":"","sources":["../../../../src/lib/runtime/runtime.mjs"],"names":[],"mappings":"AAkEA;;;;;;;;;;;;;;;;;GAiBG;AACH;CAA4B;AAE5B;;;;;;;;;;;GAWG;AACH,mBANU,YAAY,CAwCpB;AAEF;;;;;;;;;;;;;;GAcG;AACH,sBARU,MAAM,CAiCd;AAEF;;;;;;;;;GASG;AACH,yBALU,MAAM,CAiBd"} \ No newline at end of file diff --git a/types/src/lib/typegen/typegen.d.mts b/types/src/lib/typegen/typegen.d.mts index f704ad02c..2327927e0 100644 --- a/types/src/lib/typegen/typegen.d.mts +++ b/types/src/lib/typegen/typegen.d.mts @@ -11,6 +11,9 @@ * @param {string} options.interfaceName - Name of the generated TypeScript interface (e.g. `"MyApi"`). * @param {boolean|string|object} [options.typescript] - Override TypeScript loader config. Same union accepted by `slothlet({ typescript })`: pass `true` (default mode), `"fast"` / `"strict"`, or an object like `{ mode: "fast" }`. Defaults to `{ mode: "fast" }` when omitted. * @param {boolean} [options.includeDocumentation=true] - Include JSDoc comments in the generated declaration. + * @param {boolean} [options.augmentRuntime=true] - Extend `SlothletSelf` from `@cldmv/slothlet/runtime` with the + * generated interface, so leaves that import `self` get the api's types. Set `false` when another generated + * declaration in the same TypeScript program should type `self` instead. * @returns {Promise<{filePath: string, content: string}>} Absolute path written and the declaration content. * @throws {SlothletError} `INVALID_CONFIG` when `dir`, `output`, or `interfaceName` is missing or not a string. * @public @@ -30,6 +33,7 @@ export function generateTypes(options?: { interfaceName: string; typescript?: string | boolean | object | undefined; includeDocumentation?: boolean | undefined; + augmentRuntime?: boolean | undefined; }): Promise<{ filePath: string; content: string; diff --git a/types/src/lib/typegen/typegen.d.mts.map b/types/src/lib/typegen/typegen.d.mts.map index 868c62da7..6184c9e42 100644 --- a/types/src/lib/typegen/typegen.d.mts.map +++ b/types/src/lib/typegen/typegen.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"typegen.d.mts","sourceRoot":"","sources":["../../../../src/lib/typegen/typegen.mjs"],"names":[],"mappings":"AAuCA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wCAlBG;IAAwB,GAAG,EAAnB,MAAM;IACU,MAAM,EAAtB,MAAM;IACU,aAAa,EAA7B,MAAM;IAC0B,UAAU;IACxB,oBAAoB;CAC9C,GAAU,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAC,CAAC,CAoDxD"} \ No newline at end of file +{"version":3,"file":"typegen.d.mts","sourceRoot":"","sources":["../../../../src/lib/typegen/typegen.mjs"],"names":[],"mappings":"AAuCA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wCArBG;IAAwB,GAAG,EAAnB,MAAM;IACU,MAAM,EAAtB,MAAM;IACU,aAAa,EAA7B,MAAM;IAC0B,UAAU;IACxB,oBAAoB;IACpB,cAAc;CAGxC,GAAU,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAC,CAAC,CAmExD"} \ No newline at end of file diff --git a/types/src/slothlet.d.mts b/types/src/slothlet.d.mts index ac5448050..39a6f720a 100644 --- a/types/src/slothlet.d.mts +++ b/types/src/slothlet.d.mts @@ -4,8 +4,11 @@ * API instance with its own component graph, context store, and lifecycle. * @alias module:@cldmv/slothlet * @async + * @template {object} [T=import("./lib/runtime/runtime.mjs").SlothletSelf] - The loaded api's own shape. + * Defaults to `SlothletSelf`, which a `slothlet typegen` declaration extends with the project's api; + * pass another interface (`slothlet(...)`) when a program loads more than one api. * @param {SlothletOptions} config - Configuration options - * @returns {Promise} Fully loaded, proxy-based API object + * @returns {Promise} Fully loaded, proxy-based API object * @public * @example * // Minimal usage @@ -37,7 +40,7 @@ * api: { collision: { initial: "merge", api: "error" } } * }); */ -export function slothlet(config: SlothletOptions): Promise; +export function slothlet(config: SlothletOptions): Promise; export namespace slothlet { let defaults: Readonly<{ routines: ReadonlyArray<{ diff --git a/types/src/slothlet.d.mts.map b/types/src/slothlet.d.mts.map index e7bb78d91..4b2d3e7e3 100644 --- a/types/src/slothlet.d.mts.map +++ b/types/src/slothlet.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"slothlet.d.mts","sourceRoot":"","sources":["../../src/slothlet.mjs"],"names":[],"mappings":"AAs6CA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AACH,iCAjCW,eAAe,GACb,OAAO,CAAC,WAAW,CAAC,CAsChC;;kBASS,QAAQ,CAAC;QAAC,QAAQ,EAAE,aAAa,CAAC;YAAC,IAAI,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,MAAM,CAAA;SAAC,CAAC,CAAC;QAAC,eAAe,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;KAAC,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;eAgD3F,SAAS,GAAC,MAAM;;;;;;;;;;;;qBAEP,MAAM;iBAAO,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAgBhB,MAAM;eAAS,CAAC,QAAQ,GAAC,SAAS,GAAC,UAAU,GAAC,SAAS,CAAC;oBAAc,OAAO;gBAAU,CAAC,OAAO,GAAC,OAAO,CAAC;kBAAY,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;eAevI,KAAK,CAAC;YAAC,IAAI,EAAC,MAAM,CAAC;YAAA,IAAI,EAAC,MAAM,CAAC;YAAA,QAAQ,EAAC,MAAM,CAAA;SAAC,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;aA8BxD,MAAY,IAAI;;;;cAChB,MAAY,IAAI;;;;cAE3B;QAAgE,GAAG,EAAxD,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAC,SAAS,CAAC,CAAC;QACzB,GAAG,EAC/B;YAAkC,GAAG;YACH,MAAM;YACN,MAAM;YACN,MAAM;YACR,OAAO,EACvC;gBAA0C,QAAQ;gBACR,IAAI;gBACJ,SAAS;gBACT,UAAU;gBACV,YAAY;gBACZ,aAAa;gBACb,iBAAiB;gBACjB,mBAAmB;gBACnB,cAAc;aACxD;SAAA;QAA4B,OAAO,EACnC;YAAsC,GAAG;YACO,OAAO,EAA5C,MAAY,MAAM;YACS,GAAG;YACH,KAAK;YACL,GAAG;SACzC;QAA6B,IAAI;;;;;;;;6BAEV,MAAM;;;;yCACN,MAAM,EAAE;;;;;;;;;;;;;;;;;4BAIR,MAAM;;;;kCACN,MAAM;;;;;;;;6BAEN,MAAM;;;;;;;;;;;;;;;;;qCAIN,eAAe;;QACV,IAAI,EAChC;YAAmC,KAAK;YACL,OAAO;YACP,cAAc;YACd,MAAM;YACN,aAAa;YACb,IAAI;YACJ,GAAG;YACH,EAAE;YACJ,GAAG,EACpC;gBAAsC,OAAO,EAAlC,OAAO;gBACqB,MAAM;gBACN,OAAO;aAC9C;YAAmC,MAAM;YACN,kBAAkB;SACrD;QAA4B,SAAS,EACrC;YAAwC,GAAG;YACH,EAAE;SAC1C;QAA4B,WAAW,EACvC;YAAoD,GAAG,EAA5C,MAAY,MAAM;YACY,YAAY,EAA1C,OAAO;YAC0C,IAAI,EAArD,MAAY,OAAO,CAAE,IAAI,CAAC;SACrC;QAA4B,QAAQ,EACpC;YAAsD,MAAM,EAAjD,MAAY,MAAM,GAAC,IAAI;YACK,GAAG;YACH,MAAM;YACN,MAAM;YACN,SAAS;YACM,IAAI,EAA/C,MAAY,MAAM,GAAC,IAAI;YACK,GAAG;YACH,MAAM;YACN,SAAS;SAChD;QAA4B,KAAK,EACjC;YAAoC,GAAG;SACvC;QAA4B,SAAS,EACrC;YAAwC,GAAG;YACH,UAAU;SAClD;QAA4B,WAAW,EACvC;YAAwC,OAAO,EAC/C;gBAAiD,OAAO,EAA7C,OAAO;gBACwC,MAAM,EAArD,MAAY,IAAI;gBAC+B,OAAO,EAAtD,MAAY,IAAI;gBACsB,iBAAiB,EAAvD,OAAO;gBAC+C,UAAU,EAAhE,CAAS,IAAO,EAAP,OAAO,KAAG,IAAI;gBACwB,IAAI,EAAnD,MAAY,IAAI;gBACsB,MAAM,EAA5C,OAAO;aAClB;SAAA;QAA4B,UAAU,EACtC;YAAyC,IAAI;YACJ,UAAU;YACV,UAAU;YACV,kBAAkB;YAClB,kBAAkB;SAC3D;QAA8B,UAAU;QACV,IAAI;QACL,SAAS;QACR,MAAM;QACN,GAAG;QACH,KAAK;QACa,QAAQ,EAA7C,MAAY,OAAO,CAAE,IAAI,CAAC;KACvC;;gCAplD6D,wBAAwB"} \ No newline at end of file +{"version":3,"file":"slothlet.d.mts","sourceRoot":"","sources":["../../src/slothlet.mjs"],"names":[],"mappings":"AAm7CA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,yBApCuB,CAAC,SAAX,MAAQ,6DAGV,eAAe,GACb,OAAO,CAAC,WAAW,GAAG,CAAC,CAAC,CAsCpC;;kBASS,QAAQ,CAAC;QAAC,QAAQ,EAAE,aAAa,CAAC;YAAC,IAAI,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,MAAM,CAAA;SAAC,CAAC,CAAC;QAAC,eAAe,EAAE,WAAW,CAAC,MAAM,CAAC,CAAA;KAAC,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;eAgD3F,SAAS,GAAC,MAAM;;;;;;;;;;;;qBAEP,MAAM;iBAAO,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cAgBhB,MAAM;eAAS,CAAC,QAAQ,GAAC,SAAS,GAAC,UAAU,GAAC,SAAS,CAAC;oBAAc,OAAO;gBAAU,CAAC,OAAO,GAAC,OAAO,CAAC;kBAAY,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;eAevI,KAAK,CAAC;YAAC,IAAI,EAAC,MAAM,CAAC;YAAA,IAAI,EAAC,MAAM,CAAC;YAAA,QAAQ,EAAC,MAAM,CAAA;SAAC,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;aA8BxD,MAAY,IAAI;;;;cAChB,MAAY,IAAI;;;;cAE3B;QAAgE,GAAG,EAAxD,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAC,SAAS,CAAC,CAAC;QACzB,GAAG,EAC/B;YAAkC,GAAG;YACH,MAAM;YACN,MAAM;YACN,MAAM;YACR,OAAO,EACvC;gBAA0C,QAAQ;gBACR,IAAI;gBACJ,SAAS;gBACT,UAAU;gBACV,YAAY;gBACZ,aAAa;gBACb,iBAAiB;gBACjB,mBAAmB;gBACnB,cAAc;aACxD;SAAA;QAA4B,OAAO,EACnC;YAAsC,GAAG;YACO,OAAO,EAA5C,MAAY,MAAM;YACS,GAAG;YACH,KAAK;YACL,GAAG;SACzC;QAA6B,IAAI;;;;;;;;6BAEV,MAAM;;;;yCACN,MAAM,EAAE;;;;;;;;;;;;;;;;;4BAIR,MAAM;;;;kCACN,MAAM;;;;;;;;6BAEN,MAAM;;;;;;;;;;;;;;;;;qCAIN,eAAe;;QACV,IAAI,EAChC;YAAmC,KAAK;YACL,OAAO;YACP,cAAc;YACd,MAAM;YACN,aAAa;YACb,IAAI;YACJ,GAAG;YACH,EAAE;YACJ,GAAG,EACpC;gBAAsC,OAAO,EAAlC,OAAO;gBACqB,MAAM;gBACN,OAAO;aAC9C;YAAmC,MAAM;YACN,kBAAkB;SACrD;QAA4B,SAAS,EACrC;YAAwC,GAAG;YACH,EAAE;SAC1C;QAA4B,WAAW,EACvC;YAAoD,GAAG,EAA5C,MAAY,MAAM;YACY,YAAY,EAA1C,OAAO;YAC0C,IAAI,EAArD,MAAY,OAAO,CAAE,IAAI,CAAC;SACrC;QAA4B,QAAQ,EACpC;YAAsD,MAAM,EAAjD,MAAY,MAAM,GAAC,IAAI;YACK,GAAG;YACH,MAAM;YACN,MAAM;YACN,SAAS;YACM,IAAI,EAA/C,MAAY,MAAM,GAAC,IAAI;YACK,GAAG;YACH,MAAM;YACN,SAAS;SAChD;QAA4B,KAAK,EACjC;YAAoC,GAAG;SACvC;QAA4B,SAAS,EACrC;YAAwC,GAAG;YACH,UAAU;SAClD;QAA4B,WAAW,EACvC;YAAwC,OAAO,EAC/C;gBAAiD,OAAO,EAA7C,OAAO;gBACwC,MAAM,EAArD,MAAY,IAAI;gBAC+B,OAAO,EAAtD,MAAY,IAAI;gBACsB,iBAAiB,EAAvD,OAAO;gBAC+C,UAAU,EAAhE,CAAS,IAAO,EAAP,OAAO,KAAG,IAAI;gBACwB,IAAI,EAAnD,MAAY,IAAI;gBACsB,MAAM,EAA5C,OAAO;aAClB;SAAA;QAA4B,UAAU,EACtC;YAAyC,IAAI;YACJ,UAAU;YACV,UAAU;YACV,kBAAkB;YAClB,kBAAkB;SAC3D;QAA8B,UAAU;QACV,IAAI;QACL,SAAS;QACR,MAAM;QACN,GAAG;QACH,KAAK;QACa,QAAQ,EAA7C,MAAY,OAAO,CAAE,IAAI,CAAC;KACvC;;gCApmD6D,wBAAwB"} \ No newline at end of file From 3f0a8e8d07b71d3f19009aa8bfa9ea193a9d41b8 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 28 Sep 2026 11:46:57 -0700 Subject: [PATCH 13/49] fix(wrapper): don't adopt a function's non-enumerable own properties as api children Eager adoption iterated Reflect.ownKeys(impl) and skipped only `length`, `name` and `prototype` on a function impl, so any other non-enumerable own property became an enumerable child endpoint. On Node 22 every sloppy-mode (CommonJS) function carries own, non-enumerable `arguments` and `caller`, so a `module.exports = function` leaf reported Object.keys(api.x) as ["arguments", "caller"], and typegen emitted them as `unknown` members (CI's Node 22 job). A module's own Object.defineProperty(fn, k, { enumerable: false }) was adopted the same way on every Node version. Adoption now follows the get trap's rule (#304): only a function's own enumerable properties are children. The values stay readable. --- docs/generated/API.md | 3480 +---------------- src/lib/handlers/unified-wrapper.mjs | 8 + ...-wrapper-function-own-keys.test.vitest.mjs | 91 + .../lib/handlers/unified-wrapper.d.mts.map | 2 +- 4 files changed, 105 insertions(+), 3476 deletions(-) create mode 100644 tests/vitests/suites/handlers/unified-wrapper-function-own-keys.test.vitest.mjs diff --git a/docs/generated/API.md b/docs/generated/API.md index a1ee10e6e..21434c21b 100644 --- a/docs/generated/API.md +++ b/docs/generated/API.md @@ -19,106 +19,7 @@ **Structure** -[@cldmv/slothlet(config)](#at_cldmv_slash_slothlet) ⇒ Promise.<SlothletAPI> - * [.destroy()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_destroy) - * [.shutdown()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_shutdown) - * .slothlet ⇒ object - * .env ⇒ Readonly.<Record.<string, (string|undefined)>> - * .api ⇒ object - * [.add()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-api-add) - * [.reload()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-api-reload) - * [.remove()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-api-remove) - * [.leaves()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-api-leaves) - * .modules ⇒ object - * [.discover()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-api-modules-discover) - * [.sort()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-api-modules-sort) - * [.addModule()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-api-modules-addModule) - * [.addModules()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-api-modules-addModules) - * [.removeModule()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-api-modules-removeModule) - * [.addDiscovered()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-api-modules-addDiscovered) - * [.getDiscoveryCache()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-api-modules-getDiscoveryCache) - * [.clearDiscoveryCache()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-api-modules-clearDiscoveryCache) - * [.getStaleMounts()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-api-modules-getStaleMounts) - * .context ⇒ object - * [.get()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-context-get) - * [.inspect()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-context-inspect) - * [.run()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-context-run) - * [.scope()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-context-scope) - * [.set()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-context-set) - * \[.diag\] ⇒ object - * \[.caches\] ⇒ object - * [\[.get\]()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-diag-caches-get) - * [\[.getAllModuleIDs\]()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-diag-caches-getAllModuleIDs) - * [\[.has\]()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-diag-caches-has) - * \[.context\] ⇒ object - * [\[.describe\]()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-diag-describe) - * [\[.getAPI\]()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-diag-getAPI) - * [\[.getOwnership\]()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-diag-getOwnership) - * \[.hook\] ⇒ object - * [\[.inspect\]()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-diag-inspect) - * \[.owner\] ⇒ object - * [\[.get\]()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-diag-owner-get) - * \[.reference\] ⇒ object - * [\[.SlothletWarning\]()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-diag-SlothletWarning) - * .hook ⇒ object - * [.clear()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-hook-clear) - * [.disable()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-hook-disable) - * [.disablePattern()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-hook-disablePattern) - * [.enable()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-hook-enable) - * [.enablePattern()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-hook-enablePattern) - * [.list()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-hook-list) - * [.off()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-hook-off) - * [.on()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-hook-on) - * .pin ⇒ object - * .enabled ⇒ boolean - * [.enable()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-hook-pin-enable) - * [.disable()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-hook-pin-disable) - * [.remove()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-hook-remove) - * [.resetPatternFilter()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-hook-resetPatternFilter) - * .lifecycle ⇒ object - * [.off()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-lifecycle-off) - * [.on()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-lifecycle-on) - * .materialize ⇒ object - * [.get()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-materialize-get) - * .materialized ⇒ boolean - * [.wait()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-materialize-wait) - * .metadata ⇒ object - * [.caller()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-metadata-caller) - * [.get()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-metadata-get) - * [.remove()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-metadata-remove) - * [.getFor()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-metadata-getFor) - * [.removeFor()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-metadata-removeFor) - * [.self()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-metadata-self) - * [.set()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-metadata-set) - * [.setFor()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-metadata-setFor) - * [.setGlobal()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-metadata-setGlobal) - * .owner ⇒ object - * [.get()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-owner-get) - * .ownership ⇒ object - * [.get()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-ownership-get) - * [.unregister()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-ownership-unregister) - * .permissions ⇒ object - * .control ⇒ object - * .enabled ⇒ boolean - * [.enable()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-permissions-control-enable) - * [.disable()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-permissions-control-disable) - * .readGatingEnabled ⇒ boolean - * [.readGating()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-permissions-control-readGating) - * [.seal()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-permissions-control-seal) - * .sealed ⇒ boolean - * .versioning ⇒ object - * [.list()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-versioning-list) - * [.setDefault()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-versioning-setDefault) - * [.unregister()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-versioning-unregister) - * [.getVersionMetadata()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-versioning-getVersionMetadata) - * [.setVersionMetadata()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-versioning-setVersionMetadata) - * [.lockCaller()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-lockCaller) - * [.bind()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-bind) - * \[.reference\] ⇒ object - * [.reload()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-reload) - * [.run()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-run) - * [.scope()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-scope) - * [.shutdown()](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI_prop_slothlet-shutdown) +[@cldmv/slothlet(config)](#at_cldmv_slash_slothlet) ⇒ Object [@cldmv/slothlet/runtime](#at_cldmv_slash_slothlet_slash_runtime) @@ -187,7 +88,7 @@ await api.slothlet.shutdown(); -### @cldmv/slothlet(config) ⇒ Promise.<SlothletAPI> +### @cldmv/slothlet(config) ⇒ Object >

Create a new Slothlet instance and load an API from a directory. > This is the sole public entry point for slothlet. Each call produces an independent > API instance with its own component graph, context store, and lifecycle.

@@ -200,7 +101,7 @@ await api.slothlet.shutdown(); **Returns**: -- Promise.<SlothletAPI>

Fully loaded, proxy-based API object

+- Object

Fully loaded, proxy-based API object

**Example** @@ -351,3377 +252,6 @@ Only relevant when modules are registered via api.slothlet.api.add()[typescript] | boolean \| "fast" \| "strict" \| object | false | TypeScript support.
  • `false` — disabled (default).
  • `true` or `"fast"` — esbuild transpilation, no type checking.
  • `"strict"` — tsc compilation with type checking and `.d.ts` generation. See TYPESCRIPT.md for the full configuration reference.
| -* * * - - - -### SlothletAPI : object -

Bound API object returned by slothlet(). -The root contains all loaded module exports plus the reserved slothlet namespace.

- -**Kind**: typedef -**Scope**: inner - - - - -#### api.destroy() ⇒ void - -Like shutdown() but additionally invokes registered destroy hooks before teardown. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -await api.destroy(); -``` - -**Example** -```javascript -// 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.destroy(); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - await api.destroy(); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -await api.destroy(); -``` - -* * * - - - -#### api.shutdown() ⇒ void - -Convenience alias for slothlet.shutdown(). Shuts down the instance and invokes any user-provided shutdown hook first. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -await api.shutdown(); -``` - -**Example** -```javascript -// 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.shutdown(); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - await api.shutdown(); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -await api.shutdown(); -``` - -* * * - - - -#### api.slothlet.api.add(apiPath, folderPath, [options]) ⇒ Promise.<void> - -Mount a new API module at runtime. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| apiPath | string | | -| folderPath | string | | -| [options] | Object | | - -**Returns**: Promise.<void> - -**Example** -```javascript -// 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** -```javascript -// 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** -```javascript -// 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** -```javascript -// 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'); -``` - -* * * - - - -#### api.slothlet.api.reload([pathOrModuleId], [options]) ⇒ Promise.<void> - -Hot-reload a specific module or directory path. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| [pathOrModuleId] | string\|null | | -| [options] | Object | | - -**Returns**: Promise.<void> - -**Example** -```javascript -// 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** -```javascript -// 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** -```javascript -// 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** -```javascript -// 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(); -``` - -* * * - - - -#### api.slothlet.api.remove(pathOrModuleId, apiPath?) ⇒ Promise.<boolean> - -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.<boolean> - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -await api.slothlet.api.remove('utils.math'); -``` - -**Example** -```javascript -// 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** -```javascript -// 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** -```javascript -// 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'); -``` - -* * * - - - -#### api.slothlet.api.leaves(key, [options]) ⇒ Promise.<string[]|Array.<{path: string, kind: "function"|"namespace"|"data"}>> - -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. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| key | string | | -| [options] | Object | | - -**Returns**: Promise.<string[]|Array.<{path: string, kind: "function"|"namespace"|"data"}>> - -**Example** -```javascript -// 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** -```javascript -// 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** -```javascript -// 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** -```javascript -// 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'); -``` - -* * * - - - -#### api.slothlet.api.modules.discover([options]) ⇒ Promise.<DiscoverResult[]> - -Walk the filesystem for slothlet modules; replace the per-instance discovery cache and return the fresh results. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| [options] | Object | | - -**Returns**: Promise.<DiscoverResult[]> - -**Example** -```javascript -// 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-" }); -``` - -* * * - - - -#### api.slothlet.api.modules.sort(results, [comparator]) ⇒ DiscoverResult[] - -Pure sort; default comparator is priority desc with packageName asc tiebreak. Pass a custom comparator to override. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| results | DiscoverResult[] | | -| [comparator] | function | | - -**Returns**: DiscoverResult[] - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -const ordered = api.slothlet.api.modules.sort(await api.slothlet.api.modules.discover()); -``` - -* * * - - - -#### api.slothlet.api.modules.addModule(nameOrResult, [options]) ⇒ Promise.<MountResult> - -Mount one module by package name (cache lookup) or by DiscoverResult directly. Lazy-triggers discover() when the cache is empty. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| nameOrResult | string\|DiscoverResult | | -| [options] | Object | | - -**Returns**: Promise.<MountResult> - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -await api.slothlet.api.modules.addModule("@cldmv/packrat-driver-opensearch"); -``` - -* * * - - - -#### api.slothlet.api.modules.addModules(items, [options]) ⇒ Promise.<MountResult[]|Object> - -Batch mount a list of modules. Accepts a heterogeneous Array<string | DiscoverResult>. Supports onFailure: "throw" | "rollback" | "best-effort" and concurrency for parallel batches; under best-effort returns the { mounted, failed } aggregate instead of a plain array. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| items | Array.<string\|DiscoverResult> | | -| [options] | Object | | - -**Returns**: Promise.<MountResult[]|Object> - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -await api.slothlet.api.modules.addModules(["@org/a", "@org/b"], { onFailure: "best-effort", concurrency: 4 }); -``` - -* * * - - - -#### api.slothlet.api.modules.removeModule(name, [options]) ⇒ Promise.<void> - -Unmount a previously-mounted module. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| name | string | | -| [options] | Object | | - -**Returns**: Promise.<void> - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -await api.slothlet.api.modules.removeModule("@cldmv/packrat-driver-opensearch"); -``` - -* * * - - - -#### api.slothlet.api.modules.addDiscovered([options]) ⇒ Promise.<MountResult[]> - -One-shot convenience: chains discover() → sort() → addModules() for the most common boot-time flow. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| [options] | Object | | - -**Returns**: Promise.<MountResult[]> - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -await api.slothlet.api.modules.addDiscovered({ scanRoot: process.cwd(), prefix: "@cldmv/packrat-driver-" }); -``` - -* * * - - - -#### api.slothlet.api.modules.getDiscoveryCache() ⇒ DiscoverResult[] - -Snapshot of the current discovery cache. Returns the same DiscoverResult[] shape discover() produces. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: DiscoverResult[] - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -const cached = api.slothlet.api.modules.getDiscoveryCache(); -``` - -* * * - - - -#### api.slothlet.api.modules.clearDiscoveryCache() ⇒ void - -Empty the discovery cache (does NOT unmount anything already mounted). - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -api.slothlet.api.modules.clearDiscoveryCache(); -``` - -* * * - - - -#### api.slothlet.api.modules.getStaleMounts() ⇒ MountResult[] - -Reconciliation aid: returns mounts whose ${packageName}@${version} no longer appears in the discovery cache. Useful for hot-reload prune logic. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: MountResult[] - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -const stale = api.slothlet.api.modules.getStaleMounts(); -``` - -* * * - - - -#### api.slothlet.context.get([key]) ⇒ * - -Get a value from the current per-request context store. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| [key] | string | | - -**Returns**: * - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -await api.slothlet.context.run({ userId: 42 }, async () => { - const userId = api.slothlet.context.get('userId'); // 42 -}); -``` - -**Example** -```javascript -// 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.context.run({ userId: 42 }, async () => { - const userId = api.slothlet.context.get('userId'); // 42 - }); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - await api.slothlet.context.run({ userId: 42 }, async () => { - const userId = api.slothlet.context.get('userId'); // 42 - }); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -await api.slothlet.context.run({ userId: 42 }, async () => { - const userId = api.slothlet.context.get('userId'); // 42 -}); -``` - -* * * - - - -#### api.slothlet.context.inspect() ⇒ Object - -Return a snapshot of the current context state (for debugging). - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: Object - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -await api.slothlet.context.run({ userId: 42 }, async () => { - const snapshot = api.slothlet.context.inspect(); - // { data: { userId: 42 }, ... } -}); -``` - -**Example** -```javascript -// 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.context.run({ userId: 42 }, async () => { - const snapshot = api.slothlet.context.inspect(); - // { data: { userId: 42 }, ... } - }); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - await api.slothlet.context.run({ userId: 42 }, async () => { - const snapshot = api.slothlet.context.inspect(); - // { data: { userId: 42 }, ... } - }); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -await api.slothlet.context.run({ userId: 42 }, async () => { - const snapshot = api.slothlet.context.inspect(); - // { data: { userId: 42 }, ... } -}); -``` - -* * * - - - -#### api.slothlet.context.run(contextData, callback, args) ⇒ * - -Execute a callback with isolated context data merged in. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| contextData | Object | | -| callback | function | | -| args | * | | - -**Returns**: * - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -const result = await api.slothlet.context.run({ userId: 42 }, async () => { - return api.myModule.getUser(); // sees context.userId = 42 -}); -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api' }); - const result = await api.slothlet.context.run({ userId: 42 }, async () => { - return api.myModule.getUser(); // sees context.userId = 42 - }); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - const result = await api.slothlet.context.run({ userId: 42 }, async () => { - return api.myModule.getUser(); // sees context.userId = 42 - }); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -const result = await api.slothlet.context.run({ userId: 42 }, async () => { - return api.myModule.getUser(); // sees context.userId = 42 -}); -``` - -* * * - - - -#### api.slothlet.context.scope(options) ⇒ * - -Execute a function with structured context options (context, fn, args, merge, isolation). - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| options | Object | | - -**Returns**: * - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -const result = await api.slothlet.context.scope({ - context: { userId: 42 }, - fn: async () => api.myModule.getUser() -}); -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api' }); - const result = await api.slothlet.context.scope({ - context: { userId: 42 }, - fn: async () => api.myModule.getUser() - }); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - const result = await api.slothlet.context.scope({ - context: { userId: 42 }, - fn: async () => api.myModule.getUser() - }); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -const result = await api.slothlet.context.scope({ - context: { userId: 42 }, - fn: async () => api.myModule.getUser() -}); -``` - -* * * - - - -#### api.slothlet.context.set(key, value) ⇒ void - -Set a value in the current per-request context store. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| key | string | | -| value | * | | - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -await api.slothlet.context.run({}, async () => { - api.slothlet.context.set('traceId', 'abc-123'); -}); -``` - -**Example** -```javascript -// 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.context.run({}, async () => { - api.slothlet.context.set('traceId', 'abc-123'); - }); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - await api.slothlet.context.run({}, async () => { - api.slothlet.context.set('traceId', 'abc-123'); - }); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -await api.slothlet.context.run({}, async () => { - api.slothlet.context.set('traceId', 'abc-123'); -}); -``` - -* * * - - - -#### api.slothlet.diag.caches.get() ⇒ Object - -Get full cache diagnostic data ({ totalCaches, caches[] }). Only available when diagnostics: true. - -> **Requires**: `diagnostics: true` in config - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: Object - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', diagnostics: true }); -const cacheData = api.slothlet.diag.caches.get(); -// { totalCaches: 2, caches: [...] } -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', diagnostics: true }); - const cacheData = api.slothlet.diag.caches.get(); - // { totalCaches: 2, caches: [...] } -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', diagnostics: true }); - const cacheData = api.slothlet.diag.caches.get(); - // { totalCaches: 2, caches: [...] } -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', diagnostics: true }); -const cacheData = api.slothlet.diag.caches.get(); -// { totalCaches: 2, caches: [...] } -``` - -* * * - - - -#### api.slothlet.diag.caches.getAllModuleIDs() ⇒ string[] - -Return all moduleIDs currently in cache. Only available when diagnostics: true. - -> **Requires**: `diagnostics: true` in config - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: string[] - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', diagnostics: true }); -const ids = api.slothlet.diag.caches.getAllModuleIDs(); -// ['utils/math.mjs', 'utils/strings.mjs'] -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', diagnostics: true }); - const ids = api.slothlet.diag.caches.getAllModuleIDs(); - // ['utils/math.mjs', 'utils/strings.mjs'] -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', diagnostics: true }); - const ids = api.slothlet.diag.caches.getAllModuleIDs(); - // ['utils/math.mjs', 'utils/strings.mjs'] -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', diagnostics: true }); -const ids = api.slothlet.diag.caches.getAllModuleIDs(); -// ['utils/math.mjs', 'utils/strings.mjs'] -``` - -* * * - - - -#### api.slothlet.diag.caches.has(moduleID) ⇒ boolean - -Check whether a cache entry exists for a given moduleID. Only available when diagnostics: true. - -> **Requires**: `diagnostics: true` in config - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| moduleID | string | | - -**Returns**: boolean - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', diagnostics: true }); -const exists = api.slothlet.diag.caches.has('utils/math.mjs'); // true or false -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', diagnostics: true }); - const exists = api.slothlet.diag.caches.has('utils/math.mjs'); // true or false -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', diagnostics: true }); - const exists = api.slothlet.diag.caches.has('utils/math.mjs'); // true or false -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', diagnostics: true }); -const exists = api.slothlet.diag.caches.has('utils/math.mjs'); // true or false -``` - -* * * - - - -#### api.slothlet.diag.describe([showAll]) ⇒ * - -Describe API structure. Pass true to return the full API object; omit for top-level keys only. Only available when diagnostics: true. - -> **Requires**: `diagnostics: true` in config - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| [showAll] | boolean | | - -**Returns**: * - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', diagnostics: true }); -const keys = api.slothlet.diag.describe(); -const full = api.slothlet.diag.describe(true); -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', diagnostics: true }); - const keys = api.slothlet.diag.describe(); - const full = api.slothlet.diag.describe(true); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', diagnostics: true }); - const keys = api.slothlet.diag.describe(); - const full = api.slothlet.diag.describe(true); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', diagnostics: true }); -const keys = api.slothlet.diag.describe(); -const full = api.slothlet.diag.describe(true); -``` - -* * * - - - -#### api.slothlet.diag.getAPI() ⇒ Object - -Return the live bound API proxy object. Only available when diagnostics: true. - -> **Requires**: `diagnostics: true` in config - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: Object - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', diagnostics: true }); -const proxy = api.slothlet.diag.getAPI(); // the live bound API proxy -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', diagnostics: true }); - const proxy = api.slothlet.diag.getAPI(); // the live bound API proxy -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', diagnostics: true }); - const proxy = api.slothlet.diag.getAPI(); // the live bound API proxy -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', diagnostics: true }); -const proxy = api.slothlet.diag.getAPI(); // the live bound API proxy -``` - -* * * - - - -#### api.slothlet.diag.getOwnership() ⇒ Object - -Return ownership diagnostics for all registered API paths. Only available when diagnostics: true. - -> **Requires**: `diagnostics: true` in config - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: Object - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', diagnostics: true }); -const ownership = api.slothlet.diag.getOwnership(); -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', diagnostics: true }); - const ownership = api.slothlet.diag.getOwnership(); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', diagnostics: true }); - const ownership = api.slothlet.diag.getOwnership(); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', diagnostics: true }); -const ownership = api.slothlet.diag.getOwnership(); -``` - -* * * - - - -#### api.slothlet.diag.inspect() ⇒ Object - -Return a full diagnostic snapshot of current instance state. Only available when diagnostics: true. - -> **Requires**: `diagnostics: true` in config - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: Object - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', diagnostics: true }); -const snapshot = api.slothlet.diag.inspect(); -console.log(snapshot.modules, snapshot.hooks); -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', diagnostics: true }); - const snapshot = api.slothlet.diag.inspect(); - console.log(snapshot.modules, snapshot.hooks); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', diagnostics: true }); - const snapshot = api.slothlet.diag.inspect(); - console.log(snapshot.modules, snapshot.hooks); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', diagnostics: true }); -const snapshot = api.slothlet.diag.inspect(); -console.log(snapshot.modules, snapshot.hooks); -``` - -* * * - - - -#### api.slothlet.diag.owner.get(apiPath) ⇒ string[] - -Get the owning moduleIDs for a specific API path. Only available when diagnostics: true. - -> **Requires**: `diagnostics: true` in config - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| apiPath | string | | - -**Returns**: string[] - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', diagnostics: true }); -const owners = api.slothlet.diag.owner.get('math.add'); -// ['utils/math.mjs'] -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', diagnostics: true }); - const owners = api.slothlet.diag.owner.get('math.add'); - // ['utils/math.mjs'] -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', diagnostics: true }); - const owners = api.slothlet.diag.owner.get('math.add'); - // ['utils/math.mjs'] -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', diagnostics: true }); -const owners = api.slothlet.diag.owner.get('math.add'); -// ['utils/math.mjs'] -``` - -* * * - - - -#### api.slothlet.diag.SlothletWarning() ⇒ SlothletWarning - -The SlothletWarning class — access .captured for warnings emitted during tests. Only available when diagnostics: true. - -> **Requires**: `diagnostics: true` in config - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: SlothletWarning - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', diagnostics: true }); -const SlothletWarning = api.slothlet.diag.SlothletWarning; -console.log(SlothletWarning.captured); // array of captured warnings -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', diagnostics: true }); - const SlothletWarning = api.slothlet.diag.SlothletWarning; - console.log(SlothletWarning.captured); // array of captured warnings -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', diagnostics: true }); - const SlothletWarning = api.slothlet.diag.SlothletWarning; - console.log(SlothletWarning.captured); // array of captured warnings -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', diagnostics: true }); -const SlothletWarning = api.slothlet.diag.SlothletWarning; -console.log(SlothletWarning.captured); // array of captured warnings -``` - -* * * - - - -#### api.slothlet.hook.clear([filter]) ⇒ void - -Alias for remove(). - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| [filter] | Object | | - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', hook: true }); -api.slothlet.hook.clear({ type: 'before' }); -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', hook: true }); - api.slothlet.hook.clear({ type: 'before' }); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', hook: true }); - api.slothlet.hook.clear({ type: 'before' }); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', hook: true }); -api.slothlet.hook.clear({ type: 'before' }); -``` - -* * * - - - -#### api.slothlet.hook.disable([filter]) ⇒ void - -Disable hooks matching a filter (empty = disable all). - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| [filter] | Object | | - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', hook: true }); -api.slothlet.hook.disable(); // disable all -api.slothlet.hook.disable({ type: 'before' }); // disable only before hooks -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', hook: true }); - api.slothlet.hook.disable(); // disable all - api.slothlet.hook.disable({ type: 'before' }); // disable only before hooks -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', hook: true }); - api.slothlet.hook.disable(); // disable all - api.slothlet.hook.disable({ type: 'before' }); // disable only before hooks -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', hook: true }); -api.slothlet.hook.disable(); // disable all -api.slothlet.hook.disable({ type: 'before' }); // disable only before hooks -``` - -* * * - - - -#### api.slothlet.hook.disablePattern(pattern) ⇒ number - -Remove a path pattern from the runtime global path filter; when the last pattern is removed the filter deactivates and hooks apply to every path again. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| pattern | string | | - -**Returns**: number - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', hook: true }); -api.slothlet.hook.enablePattern('database.'); -api.slothlet.hook.disablePattern('database.'); // stop restricting -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', hook: true }); - api.slothlet.hook.disablePattern('database.'); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', hook: true }); - api.slothlet.hook.disablePattern('database.'); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', hook: true }); -api.slothlet.hook.disablePattern('database.*'); -``` - -* * * - - - -#### api.slothlet.hook.enable([filter]) ⇒ void - -Enable hooks matching a filter (empty = enable all). - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| [filter] | Object | | - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', hook: true }); -api.slothlet.hook.disable(); -// later... -api.slothlet.hook.enable(); // re-enable all -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', hook: true }); - api.slothlet.hook.disable(); - // later... - api.slothlet.hook.enable(); // re-enable all -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', hook: true }); - api.slothlet.hook.disable(); - // later... - api.slothlet.hook.enable(); // re-enable all -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', hook: true }); -api.slothlet.hook.disable(); -// later... -api.slothlet.hook.enable(); // re-enable all -``` - -* * * - - - -#### api.slothlet.hook.enablePattern(pattern) ⇒ number - -Restrict hook execution to an API path pattern at runtime (global path filter; the runtime form of hook.pattern). Distinct from enable/disable, which toggle individual registered hooks. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| pattern | string | | - -**Returns**: number - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', hook: true }); -api.slothlet.hook.enablePattern('database.'); // only intercept database. paths -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', hook: true }); - api.slothlet.hook.enablePattern('database.'); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', hook: true }); - api.slothlet.hook.enablePattern('database.'); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', hook: true }); -api.slothlet.hook.enablePattern('database.*'); -``` - -* * * - - - -#### api.slothlet.hook.list([filter]) ⇒ Object[] - -List registered hooks matching a filter. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| [filter] | Object | | - -**Returns**: Object[] - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', hook: true }); -const allHooks = api.slothlet.hook.list(); -const beforeHooks = api.slothlet.hook.list({ type: 'before' }); -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', hook: true }); - const allHooks = api.slothlet.hook.list(); - const beforeHooks = api.slothlet.hook.list({ type: 'before' }); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', hook: true }); - const allHooks = api.slothlet.hook.list(); - const beforeHooks = api.slothlet.hook.list({ type: 'before' }); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', hook: true }); -const allHooks = api.slothlet.hook.list(); -const beforeHooks = api.slothlet.hook.list({ type: 'before' }); -``` - -* * * - - - -#### api.slothlet.hook.off(idOrFilter) ⇒ void - -Remove hooks by ID or filter object (v2 alias for remove()). - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| idOrFilter | string\|Object | | - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', hook: true }); -const hookId = api.slothlet.hook.on('math.:before', handler); -api.slothlet.hook.off(hookId); // remove by ID -api.slothlet.hook.off({ type: 'after' }); // remove by filter -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', hook: true }); - const hookId = api.slothlet.hook.on('math.:before', handler); - api.slothlet.hook.off(hookId); // remove by ID - api.slothlet.hook.off({ type: 'after' }); // remove by filter -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', hook: true }); - const hookId = api.slothlet.hook.on('math.:before', handler); - api.slothlet.hook.off(hookId); // remove by ID - api.slothlet.hook.off({ type: 'after' }); // remove by filter -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', hook: true }); -const hookId = api.slothlet.hook.on('math.:before', handler); -api.slothlet.hook.off(hookId); // remove by ID -api.slothlet.hook.off({ type: 'after' }); // remove by filter -``` - -* * * - - - -#### api.slothlet.hook.on(typePattern, handler, [options]) ⇒ string - -Register a hook handler for a path-then-type pattern (e.g. "math.*:before"; the legacy "before:math.*" form is deprecated). - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| typePattern | string | | -| handler | function | | -| [options] | Object | | - -**Returns**: string - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', hook: true }); -const hookId = api.slothlet.hook.on('math.:before', ({ args }) => { - console.log('math called with', args); -}); -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', hook: true }); - const hookId = api.slothlet.hook.on('math.:before', ({ args }) => { - console.log('math called with', args); - }); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', hook: true }); - const hookId = api.slothlet.hook.on('math.:before', ({ args }) => { - console.log('math called with', args); - }); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', hook: true }); -const hookId = api.slothlet.hook.on('math.:before', ({ args }) => { - console.log('math called with', args); -}); -``` - -* * * - - - -#### api.slothlet.hook.pin.enable() ⇒ boolean - -Enforce hook pinning: a module hook's lockCaller: false is ignored (force-pinned). Host-only. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: boolean - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', hook: true }); -api.slothlet.hook.pin.enable(); // force-pin module hooks (host only) -``` - -* * * - - - -#### api.slothlet.hook.pin.disable() ⇒ boolean - -Stop enforcing hook pinning: permit a per-registration lockCaller: false on module hooks. Host-only. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: boolean - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', hook: true }); -api.slothlet.hook.pin.disable(); // permit unpinned module hooks (host only) -``` - -* * * - - - -#### api.slothlet.hook.remove([filter]) ⇒ void - -Remove hooks matching a filter (id, type, pattern). - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| [filter] | Object | | - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', hook: true }); -api.slothlet.hook.remove({ type: 'before', pattern: 'math.' }); -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', hook: true }); - api.slothlet.hook.remove({ type: 'before', pattern: 'math.' }); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', hook: true }); - api.slothlet.hook.remove({ type: 'before', pattern: 'math.' }); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', hook: true }); -api.slothlet.hook.remove({ type: 'before', pattern: 'math.' }); -``` - -* * * - - - -#### api.slothlet.hook.resetPatternFilter() ⇒ void - -Reset the runtime global path filter back to the configured hook.pattern default (fully unrestricted if that default was "**"). - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', hook: 'database.' }); -api.slothlet.hook.enablePattern('cache.'); -api.slothlet.hook.resetPatternFilter(); // back to database.* only -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', hook: 'database.' }); - api.slothlet.hook.resetPatternFilter(); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', hook: 'database.' }); - api.slothlet.hook.resetPatternFilter(); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', hook: 'database.*' }); -api.slothlet.hook.resetPatternFilter(); -``` - -* * * - - - -#### api.slothlet.lifecycle.off(event, handler) ⇒ void - -Unsubscribe a handler from a lifecycle event. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| event | string | | -| handler | function | | - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -const handler = () => console.log('shutdown'); -api.slothlet.lifecycle.on('shutdown', handler); -api.slothlet.lifecycle.off('shutdown', handler); -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api' }); - const handler = () => console.log('shutdown'); - api.slothlet.lifecycle.on('shutdown', handler); - api.slothlet.lifecycle.off('shutdown', handler); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - const handler = () => console.log('shutdown'); - api.slothlet.lifecycle.on('shutdown', handler); - api.slothlet.lifecycle.off('shutdown', handler); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -const handler = () => console.log('shutdown'); -api.slothlet.lifecycle.on('shutdown', handler); -api.slothlet.lifecycle.off('shutdown', handler); -``` - -* * * - - - -#### api.slothlet.lifecycle.on(event, handler) ⇒ void - -Subscribe to a lifecycle event (e.g. "materialized:complete"). - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| event | string | | -| handler | function | | - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -api.slothlet.lifecycle.on('shutdown', () => { - console.log('Slothlet is shutting down'); -}); -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api' }); - api.slothlet.lifecycle.on('shutdown', () => { - console.log('Slothlet is shutting down'); - }); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - api.slothlet.lifecycle.on('shutdown', () => { - console.log('Slothlet is shutting down'); - }); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -api.slothlet.lifecycle.on('shutdown', () => { - console.log('Slothlet is shutting down'); -}); -``` - -* * * - - - -#### api.slothlet.materialize.get() ⇒ Object - -Get current materialization statistics ({ total, materialized, remaining, percentage }). - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: Object - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', mode: 'lazy' }); -const stats = api.slothlet.materialize.get(); -// { total: 5, materialized: 3, remaining: 2, percentage: 60 } -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', mode: 'lazy' }); - const stats = api.slothlet.materialize.get(); - // { total: 5, materialized: 3, remaining: 2, percentage: 60 } -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', mode: 'lazy' }); - const stats = api.slothlet.materialize.get(); - // { total: 5, materialized: 3, remaining: 2, percentage: 60 } -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', mode: 'lazy' }); -const stats = api.slothlet.materialize.get(); -// { total: 5, materialized: 3, remaining: 2, percentage: 60 } -``` - -* * * - - - -#### api.slothlet.materialize.wait() ⇒ Promise.<void> - -Returns a Promise that resolves when all lazy folders are fully materialized. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: Promise.<void> - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', mode: 'lazy' }); -await api.slothlet.materialize.wait(); // resolves when all lazy modules are loaded -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api', mode: 'lazy' }); - await api.slothlet.materialize.wait(); // resolves when all lazy modules are loaded -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api', mode: 'lazy' }); - await api.slothlet.materialize.wait(); // resolves when all lazy modules are loaded -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api', mode: 'lazy' }); -await api.slothlet.materialize.wait(); // resolves when all lazy modules are loaded -``` - -* * * - - - -#### api.slothlet.metadata.caller() ⇒ Object|null - -Get metadata for the function that invoked the current one (runtime-injected). - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: Object|null - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -// Inside an API module, get metadata of the calling function: -const callerMeta = api.slothlet.metadata.caller(); // null if no caller -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api' }); - // Inside an API module, get metadata of the calling function: - const callerMeta = api.slothlet.metadata.caller(); // null if no caller -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - // Inside an API module, get metadata of the calling function: - const callerMeta = api.slothlet.metadata.caller(); // null if no caller -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -// Inside an API module, get metadata of the calling function: -const callerMeta = api.slothlet.metadata.caller(); // null if no caller -``` - -* * * - - - -#### api.slothlet.metadata.get(fn) ⇒ Object - -Get metadata for a specific function reference. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| fn | function | | - -**Returns**: Object - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -api.slothlet.metadata.set(api.math.add, 'label', 'Addition'); -const meta = api.slothlet.metadata.get(api.math.add); -// { label: 'Addition' } -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api' }); - api.slothlet.metadata.set(api.math.add, 'label', 'Addition'); - const meta = api.slothlet.metadata.get(api.math.add); - // { label: 'Addition' } -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - api.slothlet.metadata.set(api.math.add, 'label', 'Addition'); - const meta = api.slothlet.metadata.get(api.math.add); - // { label: 'Addition' } -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -api.slothlet.metadata.set(api.math.add, 'label', 'Addition'); -const meta = api.slothlet.metadata.get(api.math.add); -// { label: 'Addition' } -``` - -* * * - - - -#### api.slothlet.metadata.remove(fn, [key]) ⇒ void - -Remove per-function metadata (all keys or a specific key). - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| fn | function | | -| [key] | string | | - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -api.slothlet.metadata.set(api.math.add, 'label', 'Addition'); -api.slothlet.metadata.remove(api.math.add, 'label'); // remove key -api.slothlet.metadata.remove(api.math.add); // remove all -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api' }); - api.slothlet.metadata.set(api.math.add, 'label', 'Addition'); - api.slothlet.metadata.remove(api.math.add, 'label'); // remove key - api.slothlet.metadata.remove(api.math.add); // remove all -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - api.slothlet.metadata.set(api.math.add, 'label', 'Addition'); - api.slothlet.metadata.remove(api.math.add, 'label'); // remove key - api.slothlet.metadata.remove(api.math.add); // remove all -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -api.slothlet.metadata.set(api.math.add, 'label', 'Addition'); -api.slothlet.metadata.remove(api.math.add, 'label'); // remove key -api.slothlet.metadata.remove(api.math.add); // remove all -``` - -* * * - - - -#### api.slothlet.metadata.getFor(pathOrModuleId) ⇒ Object - -Get merged user metadata for an API path or moduleID. Walks root-to-leaf so descendants inherit ancestor entries automatically. Returns an empty object when no metadata is registered for the path. The returned object is not frozen — callers MUST NOT mutate it, since modifications would affect the live metadata store. Used internally by the module discovery system to round-trip the per-module manifest stored at the mount point under the _module key. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| pathOrModuleId | string | | - -**Returns**: Object - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -await api.slothlet.api.modules.addDiscovered({ scanRoot: process.cwd() }); -const meta = api.slothlet.metadata.getFor('drivers.opensearch'); -console.log(meta._module?.manifest); // { schemaVersion: 1, name: '@org/foo', version: '1.4.2', ... } -``` - -* * * - - - -#### api.slothlet.metadata.removeFor(pathOrModuleId, [key]) ⇒ void - -Remove path-level metadata for a given API path or moduleID. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| pathOrModuleId | string | | -| [key] | string\|Array.<string> | | - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -api.slothlet.metadata.removeFor('math', 'label'); // remove 'label' from all math functions -api.slothlet.metadata.removeFor('math'); // remove all metadata -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api' }); - api.slothlet.metadata.removeFor('math', 'label'); // remove 'label' from all math functions - api.slothlet.metadata.removeFor('math'); // remove all metadata -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - api.slothlet.metadata.removeFor('math', 'label'); // remove 'label' from all math functions - api.slothlet.metadata.removeFor('math'); // remove all metadata -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -api.slothlet.metadata.removeFor('math', 'label'); // remove 'label' from all math functions -api.slothlet.metadata.removeFor('math'); // remove all metadata -``` - -* * * - - - -#### api.slothlet.metadata.self() ⇒ Object|null - -Get metadata for the currently-executing API function (runtime-injected). - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: Object|null - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -api.slothlet.metadata.set(api.math.add, 'label', 'Addition'); -// Inside api.math.add, get its own metadata: -const ownMeta = api.slothlet.metadata.self(); // { label: 'Addition' } -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api' }); - api.slothlet.metadata.set(api.math.add, 'label', 'Addition'); - // Inside api.math.add, get its own metadata: - const ownMeta = api.slothlet.metadata.self(); // { label: 'Addition' } -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - api.slothlet.metadata.set(api.math.add, 'label', 'Addition'); - // Inside api.math.add, get its own metadata: - const ownMeta = api.slothlet.metadata.self(); // { label: 'Addition' } -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -api.slothlet.metadata.set(api.math.add, 'label', 'Addition'); -// Inside api.math.add, get its own metadata: -const ownMeta = api.slothlet.metadata.self(); // { label: 'Addition' } -``` - -* * * - - - -#### api.slothlet.metadata.set(fn, key, value) ⇒ void - -Set per-function metadata by direct function reference. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| fn | function | | -| key | string | | -| value | * | | - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -api.slothlet.metadata.set(api.math.add, 'label', 'Addition'); -api.slothlet.metadata.set(api.math.add, 'tags', ['math', 'util']); -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api' }); - api.slothlet.metadata.set(api.math.add, 'label', 'Addition'); - api.slothlet.metadata.set(api.math.add, 'tags', ['math', 'util']); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - api.slothlet.metadata.set(api.math.add, 'label', 'Addition'); - api.slothlet.metadata.set(api.math.add, 'tags', ['math', 'util']); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -api.slothlet.metadata.set(api.math.add, 'label', 'Addition'); -api.slothlet.metadata.set(api.math.add, 'tags', ['math', 'util']); -``` - -* * * - - - -#### api.slothlet.metadata.setFor(pathOrModuleId, keyOrObj, [value]) ⇒ void - -Set metadata for all functions reachable under an API path or moduleID. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| pathOrModuleId | string | | -| keyOrObj | string\|Object | | -| [value] | * | | - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -api.slothlet.metadata.setFor('math', 'category', 'utilities'); -api.slothlet.metadata.setFor('math', { category: 'utils', version: '1.0' }); -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api' }); - api.slothlet.metadata.setFor('math', 'category', 'utilities'); - api.slothlet.metadata.setFor('math', { category: 'utils', version: '1.0' }); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - api.slothlet.metadata.setFor('math', 'category', 'utilities'); - api.slothlet.metadata.setFor('math', { category: 'utils', version: '1.0' }); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -api.slothlet.metadata.setFor('math', 'category', 'utilities'); -api.slothlet.metadata.setFor('math', { category: 'utils', version: '1.0' }); -``` - -* * * - - - -#### api.slothlet.metadata.setGlobal(key, value) ⇒ void - -Set global metadata applied to every function in the instance. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| key | string | | -| value | * | | - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -api.slothlet.metadata.setGlobal('version', '2.0'); -// Every function now has metadata: { version: '2.0' } -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api' }); - api.slothlet.metadata.setGlobal('version', '2.0'); - // Every function now has metadata: { version: '2.0' } -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - api.slothlet.metadata.setGlobal('version', '2.0'); - // Every function now has metadata: { version: '2.0' } -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -api.slothlet.metadata.setGlobal('version', '2.0'); -// Every function now has metadata: { version: '2.0' } -``` - -* * * - - - -#### api.slothlet.owner.get(apiPath) ⇒ Object - -Get ownership info for a specific API path. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| apiPath | string | | - -**Returns**: Object - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -const info = api.slothlet.owner.get('math.add'); -// { path: 'math.add', owners: Set { 'utils/math.mjs' } } -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api' }); - const info = api.slothlet.owner.get('math.add'); - // { path: 'math.add', owners: Set { 'utils/math.mjs' } } -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - const info = api.slothlet.owner.get('math.add'); - // { path: 'math.add', owners: Set { 'utils/math.mjs' } } -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -const info = api.slothlet.owner.get('math.add'); -// { path: 'math.add', owners: Set { 'utils/math.mjs' } } -``` - -* * * - - - -#### api.slothlet.ownership.get(apiPath) ⇒ Set.<string> - -Get the set of moduleIDs that own a given API path. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| apiPath | string | | - -**Returns**: Set.<string> - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -const owners = api.slothlet.ownership.get('math.add'); -// Set { 'utils/math.mjs' } -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api' }); - const owners = api.slothlet.ownership.get('math.add'); - // Set { 'utils/math.mjs' } -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - const owners = api.slothlet.ownership.get('math.add'); - // Set { 'utils/math.mjs' } -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -const owners = api.slothlet.ownership.get('math.add'); -// Set { 'utils/math.mjs' } -``` - -* * * - - - -#### api.slothlet.ownership.unregister(moduleID) ⇒ void - -Unregister a module from all ownership records. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| moduleID | string | | - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -await api.slothlet.api.remove('math'); -api.slothlet.ownership.unregister('utils/math.mjs'); -``` - -**Example** -```javascript -// 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('math'); - api.slothlet.ownership.unregister('utils/math.mjs'); -} -``` - -**Example** -```javascript -// 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('math'); - api.slothlet.ownership.unregister('utils/math.mjs'); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -await api.slothlet.api.remove('math'); -api.slothlet.ownership.unregister('utils/math.mjs'); -``` - -* * * - - - -#### api.slothlet.permissions.control.enable() ⇒ void - -Enable permission enforcement globally. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', permissions: { defaultPolicy: 'allow' } }); -api.slothlet.permissions.control.enable(); -``` - -* * * - - - -#### api.slothlet.permissions.control.disable() ⇒ void - -Disable permission enforcement globally (all calls allowed). - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', permissions: { defaultPolicy: 'deny' } }); -api.slothlet.permissions.control.disable(); -``` - -* * * - - - -#### api.slothlet.permissions.control.readGating(value) ⇒ void - -Enable or disable read-level permission gating at runtime. Throws INVALID_ARGUMENT for a non-boolean argument. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| value | boolean | | - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', permissions: { defaultPolicy: 'allow' } }); -api.slothlet.permissions.control.readGating(false); // stop gating reads -api.slothlet.permissions.control.readGating(true); // resume -``` - -* * * - - - -#### api.slothlet.permissions.control.seal() ⇒ void - -Seal the permission control surface (one-way, no unseal). After sealing, enable/disable, addRule/removeRule, readGating, and principal.register/principal.unregister throw PERMISSION_SEALED. Enforcement continues to evaluate, principal.invalidate and shutdown() still work. Idempotent. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', permissions: { defaultPolicy: 'allow' } }); -api.slothlet.permissions.control.seal(); -``` - -* * * - - - -#### api.slothlet.versioning.list(logicalPath) ⇒ {versions: object, default: string|null}|undefined - -List all registered versions for a logical API path. Returns undefined if the path has no registered versions or if versioning has not yet been used. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| logicalPath | string | | - -**Returns**: {versions: object, default: string|null}|undefined - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', versionDispatcher: "version" }); -await api.slothlet.api.add('auth', './api/v1', {}, { version: 'v1', default: true }); -const info = api.slothlet.versioning.list('auth'); -if (info) console.log(info.default); // "v1" -``` - -* * * - - - -#### api.slothlet.versioning.setDefault(logicalPath, versionTag) ⇒ void - -Override the default version for a logical API path at runtime. If no versions are registered for the path, this call has no effect until versioning data exists. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| logicalPath | string | | -| versionTag | string | | - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', versionDispatcher: "version" }); -await api.slothlet.api.add('auth', './api/v1', {}, { version: 'v1' }); -await api.slothlet.api.add('auth', './api/v2', {}, { version: 'v2' }); -api.slothlet.versioning.setDefault('auth', 'v1'); -``` - -* * * - - - -#### api.slothlet.versioning.unregister(logicalPath, versionTag) ⇒ Promise.<boolean> - -Unregister a specific version from a logical API path. Removes the versioned namespace (vX.path) and updates or tears down the dispatcher. If the logical path/version is not registered, this resolves without removing anything. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| logicalPath | string | | -| versionTag | string | | - -**Returns**: Promise.<boolean> - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', versionDispatcher: "version" }); -await api.slothlet.api.add('auth', './api/v1', {}, { version: 'v1' }); -await api.slothlet.api.add('auth', './api/v2', {}, { version: 'v2' }); -const removed = await api.slothlet.versioning.unregister('auth', 'v2'); // true -``` - -* * * - - - -#### api.slothlet.versioning.getVersionMetadata(logicalPath, versionTag) ⇒ object|undefined - -Retrieve the VersionManager-only metadata stored at registration time via versionConfig.metadata. Returns undefined if the path or version is not registered, or if versioning has not yet been used. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| logicalPath | string | | -| versionTag | string | | - -**Returns**: object|undefined - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', versionDispatcher: "version" }); -await api.slothlet.api.add('auth', './api/v1', {}, { version: 'v1', metadata: { stable: true } }); -const meta = api.slothlet.versioning.getVersionMetadata('auth', 'v1'); // { stable: true, version: 'v1', logicalPath: 'auth' } -``` - -* * * - - - -#### api.slothlet.versioning.setVersionMetadata(logicalPath, versionTag, patch) ⇒ void - -Patch (merge) the VersionManager-only metadata for a registered version at runtime. The injected version and logicalPath keys always win over supplied patch fields. Throws VERSION_NOT_FOUND if the path or version is not registered. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| logicalPath | string | | -| versionTag | string | | -| patch | object | | - -**Returns**: void - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api', versionDispatcher: "version" }); -await api.slothlet.api.add('auth', './api/v1', {}, { version: 'v1' }); -api.slothlet.versioning.setVersionMetadata('auth', 'v1', { stable: true }); -``` - -* * * - - - -#### api.slothlet.lockCaller(fn) ⇒ function - -Freeze the registering module's caller identity onto a callback. The returned wrapper overrides only the caller identity when invoked; request-scoped context stays live. In async runtime mode the pinned identity propagates through AsyncLocalStorage for the callback's full async lifetime. In live runtime mode identity is pinned only for the synchronous portion — once fn returns its promise the live context is restored, so an async callback that awaits before calling self.* resumes with no slothlet caller at all (an identity check sees unknown); use async runtime mode for async hooks (e.g. Fastify addHook) that must keep identity past the first await. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| fn | function | | - -**Returns**: function - -**Example** -```javascript -// Inside a module's init() -server.addHook("onRequest", self.slothlet.lockCaller(handler)); -``` - -* * * - - - -#### api.slothlet.bind(fn) ⇒ function - -Freeze the entire async context onto a callback at registration time (convenience re-export of AsyncResource.bind). Unlike lockCaller, which overrides only caller identity and leaves request-scoped context live, bind freezes every AsyncLocalStorage, including slothlet's caller and context. Meaningful in async runtime mode; degrades to non-slothlet ALS context in live mode. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| fn | function | | - -**Returns**: function - -**Example** -```javascript -// Inside a module's init() -server.addHook("onRequest", self.slothlet.bind(handler)); -``` - -* * * - - - -#### api.slothlet.reload([options]) ⇒ Promise.<void> - -Reload the entire instance (re-scans the directory and recreates all module references). Accepts { keepInstanceID: boolean }. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| [options] | Object | | - -**Returns**: Promise.<void> - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -await api.slothlet.reload(); // full reload -``` - -**Example** -```javascript -// 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.reload(); // full reload -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - await api.slothlet.reload(); // full reload -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -await api.slothlet.reload(); // full reload -``` - -* * * - - - -#### api.slothlet.run(contextData, callback, args) ⇒ * - -Execute a callback with isolated per-request context data. Convenience alias for slothlet.context.run(). - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| contextData | Object | | -| callback | function | | -| args | * | | - -**Returns**: * - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -const result = await api.slothlet.run({ userId: 42 }, async () => { - return api.myModule.getUser(); -}); -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api' }); - const result = await api.slothlet.run({ userId: 42 }, async () => { - return api.myModule.getUser(); - }); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - const result = await api.slothlet.run({ userId: 42 }, async () => { - return api.myModule.getUser(); - }); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -const result = await api.slothlet.run({ userId: 42 }, async () => { - return api.myModule.getUser(); -}); -``` - -* * * - - - -#### api.slothlet.scope(options) ⇒ * - -Execute a function with full structured per-request context options. Convenience alias for slothlet.context.scope(). - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -| Param | Type | Description | -| --- | --- | --- | -| options | Object | | - -**Returns**: * - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -const result = await api.slothlet.scope({ - context: { userId: 42 }, - fn: async () => api.myModule.getUser() -}); -``` - -**Example** -```javascript -// ESM usage via slothlet API (inside async function) -async function example() { - const { default: slothlet } = await import("@cldmv/slothlet"); - const api = await slothlet({ base: './api' }); - const result = await api.slothlet.scope({ - context: { userId: 42 }, - fn: async () => api.myModule.getUser() - }); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - const result = await api.slothlet.scope({ - context: { userId: 42 }, - fn: async () => api.myModule.getUser() - }); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -const result = await api.slothlet.scope({ - context: { userId: 42 }, - fn: async () => api.myModule.getUser() -}); -``` - -* * * - - - -#### api.slothlet.shutdown() ⇒ Promise.<void> - -Shut down the instance and release all resources. - -**Kind**: function property of [SlothletAPI](#typedef_module_at_cldmv_slash_slothlet_SlothletAPI) - -**Returns**: Promise.<void> - -**Example** -```javascript -// ESM usage via slothlet API -import slothlet from "@cldmv/slothlet"; -const api = await slothlet({ base: './api' }); -await api.slothlet.shutdown(); -``` - -**Example** -```javascript -// 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.shutdown(); -} -``` - -**Example** -```javascript -// CJS usage via slothlet API (top-level) -let slothlet; -(async () => { - ({ slothlet } = await import("@cldmv/slothlet")); - const api = await slothlet({ base: './api' }); - await api.slothlet.shutdown(); -})(); -``` - -**Example** -```javascript -// CJS usage via slothlet API (inside async function) -const slothlet = require("@cldmv/slothlet"); -const api = await slothlet({ base: './api' }); -await api.slothlet.shutdown(); -``` - -* * * - - -| Property | Type | Description | -| --- | --- | --- | -| slothlet | object | Built-in control namespace. All framework internals live here to avoid collisions with loaded modules. | -| slothlet.env | Readonly.> | Frozen snapshot of `process.env` captured once at instance creation (before any module lifecycle runs). Immutable for the lifetime of the instance — reloads do NOT refresh it. When `env.include` is configured, only the listed keys are captured; otherwise, the full environment is snapshotted. | -| slothlet.api | object | Runtime API mutation methods — availability controlled by `api.mutations` config option. | -| slothlet.api.modules | object | 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`, which also covers multi-version routing, error codes, and the `modules:*` lifecycle events. | -| slothlet.context | object | Per-request context helpers. | -| [slothlet.diag] | object | Diagnostics namespace — only present when `diagnostics: true`. Do not enable in production. | -| [slothlet.diag.caches] | object | Cache diagnostics sub-namespace. | -| [slothlet.diag.context] | object | The `context` config value as passed to `slothlet()`. | -| [slothlet.diag.hook] | object | Hook system diagnostics sub-namespace (present only when hooks are enabled). | -| [slothlet.diag.owner] | object | Ownership sub-namespace for diagnostics. | -| [slothlet.diag.reference] | object | The `reference` config value as passed to `slothlet()`. | -| slothlet.hook | object | Hook registration surface — only present when the `hook` option is enabled. | -| slothlet.hook.pin | object | Host-only runtime control over hook-pinning enforcement (mirrors `permissions.control`). "Host-only" is enforced by the built-in `slothlet.hook.**` permission deny baseline, so it only holds when the permission system is enabled — without permissions a module reaching the hook surface can toggle it. Force-pinning makes a module hook always run under its registering module's identity; an unpinned module hook is a permission-bypass vector. Enabled by default (config `hook.pin`). | -| slothlet.hook.pin.enabled | boolean | Whether hook pinning is currently enforced (true = module hooks are force-pinned, the default). %%sig: boolean%% %%example: // ESM usage via slothlet API\|import slothlet from "@cldmv/slothlet";\|const api = await slothlet({ base: './api', hook: true });\|const enforced = api.slothlet.hook.pin.enabled;%% | -| slothlet.lifecycle | object | Lifecycle event emitter. | -| slothlet.materialize | object | Lazy materialization tracking (meaningful only when `mode: "lazy"`). | -| slothlet.materialize.materialized | boolean | `true` once all lazy folders have been fully loaded. | -| slothlet.metadata | object | Module metadata accessor. | -| slothlet.owner | object | Direct path ownership accessor (shorthand for `slothlet.ownership`). | -| slothlet.ownership | object | Module ownership registry. | -| slothlet.permissions | object | Permission system surface — present whenever a `permissions` block is configured. Rule registration (`addRule`/`removeRule`), principal resolvers (`principal.*`), self/global introspection (`self.*`, `global.*`), and runtime control (`control.*`) live here. Only the `control` sub-namespace is typed in this typedef; the rule-management methods are documented in `docs/PERMISSIONS.md`. | -| slothlet.permissions.control | object | Runtime control over permission enforcement. | -| slothlet.permissions.control.enabled | boolean | Current permission-enforcement state. Exposed as a getter so descriptor-based reads remain permission-gated. %%sig: boolean%% %%example: // ESM usage via slothlet API\|import slothlet from "@cldmv/slothlet";\|const api = await slothlet({ base: './api', permissions: { defaultPolicy: 'allow' } });\|const on = api.slothlet.permissions.control.enabled;%% | -| slothlet.permissions.control.readGatingEnabled | boolean | Current read-level gating state. `true` when terminal data-value property reads are permission-gated. Exposed as a getter so descriptor-based reads remain permission-gated. %%sig: boolean%% %%example: // ESM usage via slothlet API\|import slothlet from "@cldmv/slothlet";\|const api = await slothlet({ base: './api', permissions: { defaultPolicy: 'allow' } });\|const gated = api.slothlet.permissions.control.readGatingEnabled;%% | -| slothlet.permissions.control.sealed | boolean | Whether the permission control surface has been sealed. Exposed as a getter so descriptor-based reads remain permission-gated. %%sig: boolean%% %%example: // ESM usage via slothlet API\|import slothlet from "@cldmv/slothlet";\|const api = await slothlet({ base: './api', permissions: { defaultPolicy: 'allow' } });\|const sealed = api.slothlet.permissions.control.sealed;%% | -| slothlet.versioning | object | Runtime version management API. This namespace is always present on `api.slothlet`; before any module has been registered via `api.slothlet.api.add()` with a `versionConfig` argument, its methods return `undefined` where documented or otherwise have no effect until versioning data exists. | -| [slothlet.reference] | object | The `reference` object from config, merged onto the root API and accessible here. | - - * * * @@ -3743,8 +273,8 @@ await api.slothlet.shutdown(); > > > self -> object -> Live reference to the full Slothlet API proxy. Use to call sibling modules without import cycles. +> SlothletSelf +> Live reference to the full Slothlet API proxy. Use to call sibling modules without import cycles. Typed by extending SlothletSelf (see slothlet typegen). > > > context diff --git a/src/lib/handlers/unified-wrapper.mjs b/src/lib/handlers/unified-wrapper.mjs index 44a96b74d..d1ca9770a 100644 --- a/src/lib/handlers/unified-wrapper.mjs +++ b/src/lib/handlers/unified-wrapper.mjs @@ -1864,6 +1864,14 @@ export class UnifiedWrapper extends ComponentBase { continue; } /* v8 ignore stop */ + // A function's non-enumerable own properties are its own surface, not child endpoints — the + // same rule the get trap applies (#304). Beyond `length`/`name`/`prototype` (skipped above), + // this covers a module's own `Object.defineProperty(fn, k, { enumerable: false })` and the own + // `arguments`/`caller` every sloppy-mode (CommonJS) function carries on Node 22; adopting them + // made them enumerable api children that `Object.keys()` and typegen reported. + if (typeof this.____slothletInternal.impl === "function" && !descriptor.enumerable) { + continue; + } const value = this.____slothletInternal.impl[key]; // A value that IS the impl itself (circular reference) never appears in module exports; this guard is unreachable. /* v8 ignore start */ diff --git a/tests/vitests/suites/handlers/unified-wrapper-function-own-keys.test.vitest.mjs b/tests/vitests/suites/handlers/unified-wrapper-function-own-keys.test.vitest.mjs new file mode 100644 index 000000000..caf091c5b --- /dev/null +++ b/tests/vitests/suites/handlers/unified-wrapper-function-own-keys.test.vitest.mjs @@ -0,0 +1,91 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/handlers/unified-wrapper-function-own-keys.test.vitest.mjs + * @Date: 2026-09-28 10:39:19 -07:00 (1790617159) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 11:39:06 -07:00 (1790620746) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Regression: eager adoption turned a function leaf's NON-enumerable own properties into + * enumerable api children. Adoption iterated `Reflect.ownKeys(impl)` and skipped only `length`, `name` + * and `prototype`, so any other non-enumerable own property became a child endpoint that + * `Object.keys()` reports. On Node 22 every sloppy-mode function (all CommonJS) carries own, + * non-enumerable `arguments` and `caller` properties, so a `module.exports = function` leaf surfaced + * `Object.keys(api.tools.count)` → `["arguments", "caller"]` in eager mode (Node 24+ no longer has + * them, which hid it locally; typegen then emitted them as `unknown` members on CI's Node 22 job). + * + * The rule matches the get trap's (#304): only a function's own ENUMERABLE properties are children. + * + * @module tests/vitests/suites/handlers/unified-wrapper-function-own-keys + */ + +import { describe, it, expect, afterEach } from "vitest"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import slothlet from "@cldmv/slothlet"; +import { makeTestTmpDir } from "../../setup/test-fixtures-tmp.mjs"; + +/** + * Write the fixture tree: a function leaf with a non-enumerable and an enumerable own property, and a + * sloppy-mode CommonJS function leaf. + * @returns {Promise} The api base directory. + */ +async function writeFixture() { + const root = await makeTestTmpDir("fn-own-keys"); + const tools = join(root, "api", "tools"); + await mkdir(tools, { recursive: true }); + await writeFile( + join(tools, "tag.mjs"), + [ + "export function tag(value) { return `#${value}`; }", + 'Object.defineProperty(tag, "hidden", { value: "internal", enumerable: false, writable: false, configurable: true });', + 'tag.visible = "public";', + "" + ].join("\n"), + "utf8" + ); + await writeFile(join(tools, "count.cjs"), "module.exports = function count(s) { return s.length; };\n", "utf8"); + return join(root, "api"); +} + +describe.each([{ mode: "eager" }, { mode: "lazy" }])("function leaf own keys ($mode)", ({ mode }) => { + let api; + let base; + + afterEach(async () => { + if (api?.shutdown) await api.shutdown().catch(() => {}); + api = null; + if (base) await rm(join(base, ".."), { recursive: true, force: true }); + base = null; + }); + + it("does not adopt a function's non-enumerable own property as an api child", async () => { + base = await writeFixture(); + api = await slothlet({ base, mode, silent: true }); + expect(await api.tools.tag("x")).toBe("#x"); + expect(Object.keys(api.tools.tag)).not.toContain("hidden"); + // Still readable, exactly as on the function itself. + expect(api.tools.tag.hidden).toBe("internal"); + }); + + it("still adopts a function's enumerable own property", async () => { + base = await writeFixture(); + api = await slothlet({ base, mode, silent: true }); + expect(await api.tools.tag("x")).toBe("#x"); + expect(Object.keys(api.tools.tag)).toContain("visible"); + expect(api.tools.tag.visible).toBe("public"); + }); + + it("gives a sloppy-mode CommonJS function leaf no extra keys", async () => { + base = await writeFixture(); + api = await slothlet({ base, mode, silent: true }); + expect(await api.tools.count("abc")).toBe(3); + expect(Object.keys(api.tools.count)).toEqual([]); + }); +}); diff --git a/types/src/lib/handlers/unified-wrapper.d.mts.map b/types/src/lib/handlers/unified-wrapper.d.mts.map index b608810a1..f90d04420 100644 --- a/types/src/lib/handlers/unified-wrapper.d.mts.map +++ b/types/src/lib/handlers/unified-wrapper.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"unified-wrapper.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/unified-wrapper.mjs"],"names":[],"mappings":"AA0xBA;;;;;;;;;;;;;;;;;;GAkBG;AACH,4CAhBW,MAAM,GAAC,MAAM,GACX,OAAO,CAkBnB;AAi0ID;;;;;;;;;;;GAWG;AACH,sCAPW,OAAO,GACL,cAAc,GAAC,IAAI,CAkB/B;AA13ID;;;;;;;;GAQG;AACH,iCAHU,GAAG,CAAC,MAAM,CAAC,CAG2F;;;;;AAuEhH;;;;;;;;;;GAUG;AACH;IAuUC;;;;;;;;;;;;;;;;OAgBG;IACH,0BAsCC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,gCAiEC;IAzbD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAoCG;IACH,sBApCW,MAAM,mKAEd;QAAwB,IAAI,EAApB,MAAM;QACU,OAAO,EAAvB,MAAM;QACyB,WAAW;QACvB,eAAe;QAChB,UAAU;QACV,mBAAmB;QACpB,QAAQ;QACD,UAAU;QAGjB,QAAQ;QACR,YAAY;QACE,cAAc;QAI3B,eAAe;KAMzC,EA+NF;IAzQD;;;;;;;;;;;OAWG;IACH,4BAFa,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAC,SAAS,CAKzC;IA4PD;;;;;;;;;;OAUG;IACH,uEAHa,GAAC,CA+Bb;IAED;;;;OAIG;IACH,qBAHa,WAAS,MAAM,GAAC,IAAI,CAKhC;IAiJD;;;;;;;;;;;OAWG;IACH,sBA0CC;IAED;;;;;;;;;;;;OAYG;IACH,mBAkDC;IAED;;;;;;;;;OASG;IACH,qBAsDC;IAED;;;;OAIG;IACH,uBA4GC;IAED;;;;;;;;;OASG;IACH,qBAEC;IAED;;;;;;;;;OASG;IACH,sBAiBC;IAED;;;;;;;;;;OAUG;IACH,6BA4gBC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BA8PC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BA43BC;IAED;;;;;;OAMG;IACH,uCAswDC;;CACD;8BAtkK6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"unified-wrapper.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/unified-wrapper.mjs"],"names":[],"mappings":"AA0xBA;;;;;;;;;;;;;;;;;;GAkBG;AACH,4CAhBW,MAAM,GAAC,MAAM,GACX,OAAO,CAkBnB;AA02ID;;;;;;;;;;;GAWG;AACH,sCAPW,OAAO,GACL,cAAc,GAAC,IAAI,CAkB/B;AAn6ID;;;;;;;;GAQG;AACH,iCAHU,GAAG,CAAC,MAAM,CAAC,CAG2F;;;;;AAuEhH;;;;;;;;;;GAUG;AACH;IAuUC;;;;;;;;;;;;;;;;OAgBG;IACH,0BAsCC;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,gCAiEC;IAzbD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAoCG;IACH,sBApCW,MAAM,mKAEd;QAAwB,IAAI,EAApB,MAAM;QACU,OAAO,EAAvB,MAAM;QACyB,WAAW;QACvB,eAAe;QAChB,UAAU;QACV,mBAAmB;QACpB,QAAQ;QACD,UAAU;QAGjB,QAAQ;QACR,YAAY;QACE,cAAc;QAI3B,eAAe;KAMzC,EA+NF;IAzQD;;;;;;;;;;;OAWG;IACH,4BAFa,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAC,SAAS,CAKzC;IA4PD;;;;;;;;;;OAUG;IACH,uEAHa,GAAC,CA+Bb;IAED;;;;OAIG;IACH,qBAHa,WAAS,MAAM,GAAC,IAAI,CAKhC;IAiJD;;;;;;;;;;;OAWG;IACH,sBA0CC;IAED;;;;;;;;;;;;OAYG;IACH,mBAkDC;IAED;;;;;;;;;OASG;IACH,qBAsDC;IAED;;;;OAIG;IACH,uBA4GC;IAED;;;;;;;;;OASG;IACH,qBAEC;IAED;;;;;;;;;OASG;IACH,sBAiBC;IAED;;;;;;;;;;OAUG;IACH,6BAohBC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BA8PC;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,8BA43BC;IAED;;;;;;OAMG;IACH,uCAuyDC;;CACD;8BA/mK6B,2BAA2B"} \ No newline at end of file From d2ac48c27ab6689e6b6b8ec8af86ddcb6a2d7c8b Mon Sep 17 00:00:00 2001 From: "cldmv-bot[bot]" <230771808+cldmv-bot[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:27:03 +0000 Subject: [PATCH 14/49] chore: bump version to 3.22.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b9d6961ba..e1dc8d79a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cldmv/slothlet", - "version": "3.21.1", + "version": "3.22.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@cldmv/slothlet", - "version": "3.21.1", + "version": "3.22.0", "license": "Apache-2.0", "bin": { "slothlet": "bin/slothlet.mjs" diff --git a/package.json b/package.json index 446c8b530..688ca6631 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cldmv/slothlet", - "version": "3.21.1", + "version": "3.22.0", "moduleVersions": { "lazy": "3.0.0", "eager": "3.0.0", From aba22f8995f58954f9ba2db37c78d3ecd427a0db Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 28 Sep 2026 13:54:22 -0700 Subject: [PATCH 15/49] =?UTF-8?q?feat(permissions):=20add=20global.checkCa?= =?UTF-8?q?ll=20=E2=80=94=20call-gate=20query=20with=20args,=20principals,?= =?UTF-8?q?=20and=20audit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trusted boundary layer (slothlet-vine) that forwards a remote module's call has to answer, on the serving side, the question the call gate would answer for a live call: "may caller X call target Y with these args". `global.checkAccess` cannot: it is a silent query that forwards null call metadata (so an args-based condition never sees the resource named in the call), never resolves a stale `requires` principal, emits no audit event, and treats a caller with no source file as the host. `api.slothlet.permissions.global.checkCall(callerPath, targetPath, args)` is the call-side twin of `event.resolveLevel`: - Forwards `callMeta = { args, target }` to function conditions — the exact shape `enforcePermission` builds — and evaluates against the ambient `context.run()` context, like checkAccess/resolveLevel. - Mirrors the promoted call gate for principals (#459): scans for stale `requires` principals, resolves them, then enforces with principal grace. Only that path returns a Promise; the fast path is synchronous. - The supplied caller is a module without a source file: the self-call bypass never applies, and a module-private (`_`-prefixed) target is denied outright rather than judged by `permissions.private.host`. This is expressed explicitly — a new `moduleCaller` enforcement option threaded to #resolveAccess's private-name branch — instead of faking a file path. checkAccess's behaviour (null callMeta, host semantics for a null caller file) is unchanged. - Emits the audit lifecycle events the real gate emits, with an extra `via: "checkCall"` payload field (spread onto the decision record, so a cached record is never mutated) so a probing peer is visible and distinguishable from a real call. - Answers `true` when enforcement is disabled, like checkAccess. - Validates inputs like the neighbouring APIs: INVALID_ARGUMENT for a non-string/empty caller or target and for a non-array `args`; `undefined`/`null` args mean `[]`. No new i18n codes were needed. Host-only gating: resolveLevel is kept off modules by the built-in `slothlet.event.**` deny, whereas `global.checkAccess` is merely gatable (no built-in deny — a module reaches it under an allow default, as the existing gating suite and the `gatedInternalRoute` fixture rely on). Those differ, and the stricter one is right for checkCall: a module that could ask on another identity's behalf would learn that identity's rule outcomes, and the audit trail would misattribute the probe. So checkCall gets a built-in `{ caller: "**", target: "slothlet.permissions.global.checkCall", effect: "deny" }`, on the exact target so an instance rule on that same target (equal specificity, higher layer) is how the host grants it to a trusted boundary module. checkAccess keeps its current reachability — tightening it is out of scope here. Tests cover, across the eager/lazy × async/live × hooks matrix: parity with checkAccess for unconditional rules, args- and target-based conditions (and that checkAccess still passes null), ambient context, the stale-principal Promise path vs the fresh sync path, private-target denial under both host policies, no self-call bypass, audit payloads under default and verbose audit, disabled enforcement, host-only refusal plus the exact-target grant, and input validation; plus a direct PermissionManager describe for the `moduleCaller` / `via` options and the cache-record immutability. `types/` and `docs/generated/` are left for the release build, which is where this repo regenerates them. Fixes #508 --- .../callers/check-call-caller.mjs | 48 ++ docs/EVENTS.md | 2 +- docs/PERMISSIONS-CONDITIONS.md | 3 +- docs/PERMISSIONS.md | 45 +- src/lib/builders/api_builder.mjs | 37 ++ src/lib/handlers/permission-manager.mjs | 127 ++++- ...missions-global-check-call.test.vitest.mjs | 442 ++++++++++++++++++ 7 files changed, 687 insertions(+), 17 deletions(-) create mode 100644 api_tests/api_test_permissions/callers/check-call-caller.mjs create mode 100644 tests/vitests/suites/permissions/permissions-global-check-call.test.vitest.mjs diff --git a/api_tests/api_test_permissions/callers/check-call-caller.mjs b/api_tests/api_test_permissions/callers/check-call-caller.mjs new file mode 100644 index 000000000..1b6734556 --- /dev/null +++ b/api_tests/api_test_permissions/callers/check-call-caller.mjs @@ -0,0 +1,48 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_permissions/callers/check-call-caller.mjs + * @Date: 2026-09-28 12:00:00 -07:00 (1790622000) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 12:00:00 -07:00 (1790622000) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +import { self } from "@cldmv/slothlet/runtime"; + +/** + * Attempt the host-only `permissions.global.checkCall` from a MODULE context (#508), so a test can + * assert a module caller is REFUSED rather than answered — and, when the host has granted it, that + * the answer comes through. Returns the outcome instead of throwing. + * @param {string} callerPath - Identity to ask about. + * @param {string} targetPath - Target api path. + * @param {Array<*>} [args] - Arguments of the call being asked about. + * @returns {Promise<{ ok: boolean, allowed?: boolean, code?: string }>} `ok:true` + the decision when + * permitted; otherwise `ok:false` + the error code. + */ +export async function attempt(callerPath, targetPath, args) { + try { + return { ok: true, allowed: await self.slothlet.permissions.global.checkCall(callerPath, targetPath, args) }; + } catch (error) { + return { ok: false, code: error.code || error.name }; + } +} + +/** + * The gatable sibling, for contrast: `global.checkAccess` carries no built-in deny, so under an + * allow default a module reaches it. Returns the outcome instead of throwing. + * @param {string} callerPath - Identity to ask about. + * @param {string} targetPath - Target api path. + * @returns {Promise<{ ok: boolean, allowed?: boolean, code?: string }>} `ok:true` + the decision when + * permitted; otherwise `ok:false` + the error code. + */ +export async function attemptAccess(callerPath, targetPath) { + try { + return { ok: true, allowed: await self.slothlet.permissions.global.checkAccess(callerPath, targetPath) }; + } catch (error) { + return { ok: false, code: error.code || error.name }; + } +} diff --git a/docs/EVENTS.md b/docs/EVENTS.md index 60049fa65..ed1ae7d6a 100644 --- a/docs/EVENTS.md +++ b/docs/EVENTS.md @@ -70,7 +70,7 @@ A **host** subscription — one made with no module caller in context (the compo `resolveLevel(subscriberPath, event)` answers the delivery level a **supplied** subscriber identity would be granted for an event — `deny` / `notify` / `allow` — without registering anything. It is the inverse of `on`: `on` derives the subscriber from the live caller and never trusts a supplied identity, whereas `resolveLevel` is answered _for_ an identity the caller names. Because it lets one caller ask about another identity, it is **host-only** — reachable only from the composing host (or a `run()` / `scope()` descended from it), gated exactly like `rules.*` by the built-in `slothlet.event.**` deny. A `null` `subscriberPath` is a host subscription and always resolves `allow`. -The motivating consumer is a cross-boundary event-forwarding layer such as [`@cldmv/slothlet-vine`](https://github.com/CLDMV/slothlet-vine). Slothlet stays boundary-agnostic; a forwarding layer that carries an instance's events to a subscriber living in _another_ instance must enforce that remote subscriber's level on the **trusted (serving) side**, stripping the domain payload before it crosses to an instance that cannot be trusted to withhold it. `resolveLevel` is how the trusted side resolves that remote subscriber's level; the boundary layer then delivers a trigger-only or trigger-plus-payload frame accordingly, and never re-implements the policy itself. +The motivating consumer is a cross-boundary event-forwarding layer such as [`@cldmv/slothlet-vine`](https://github.com/CLDMV/slothlet-vine). Slothlet stays boundary-agnostic; a forwarding layer that carries an instance's events to a subscriber living in _another_ instance must enforce that remote subscriber's level on the **trusted (serving) side**, stripping the domain payload before it crosses to an instance that cannot be trusted to withhold it. `resolveLevel` is how the trusted side resolves that remote subscriber's level; the boundary layer then delivers a trigger-only or trigger-plus-payload frame accordingly, and never re-implements the policy itself. Its call-side twin is [`permissions.global.checkCall(caller, target, args)`](PERMISSIONS.md#checkcall-vs-checkaccess), which answers — for a supplied caller identity — whether the call gate would allow a specific call, host-only in the same way. ```javascript // On the serving instance, a forwarding layer resolves a remote subscriber's level, then strips before crossing: diff --git a/docs/PERMISSIONS-CONDITIONS.md b/docs/PERMISSIONS-CONDITIONS.md index 3e27e81de..d686076bb 100644 --- a/docs/PERMISSIONS-CONDITIONS.md +++ b/docs/PERMISSIONS-CONDITIONS.md @@ -112,7 +112,7 @@ At a **call** or **construct** gate the function condition also receives a secon } ``` -`callMeta` is provided at the **call** gate (`self.x.leaf(...)`), the **construct** gate (`new self.x.Foo(...)`), and when a **captured reference** (`const f = self.x.leaf; f(...)`) is later invoked — all three see the invocation's real arguments. It is **`null`** everywhere else a condition is evaluated: terminal-value **reads**, **hook** gating, **event** delivery, the internal `slothlet.*` control surface, and silent queries (`permissions.global.checkAccess`, `permissions.self.access`, `matchesCondition`). Because `target` is the concrete leaf (never the rule's glob), one `**`-globbed rule can branch on exactly which leaf was called. +`callMeta` is provided at the **call** gate (`self.x.leaf(...)`), the **construct** gate (`new self.x.Foo(...)`), and when a **captured reference** (`const f = self.x.leaf; f(...)`) is later invoked — all three see the invocation's real arguments. It is **`null`** everywhere else a condition is evaluated: terminal-value **reads**, **hook** gating, **event** delivery, the internal `slothlet.*` control surface, and silent queries (`permissions.global.checkAccess`, `permissions.self.access`, `matchesCondition`). The one query that is _not_ silent is the host-only [`permissions.global.checkCall(caller, target, args)`](./PERMISSIONS.md#checkcall-vs-checkaccess): it is handed the arguments of the call being asked about and forwards `{ args, target }` exactly as the call gate does, so a condition sees the same second argument whether the call is real or queried. Because `target` is the concrete leaf (never the rule's glob), one `**`-globbed rule can branch on exactly which leaf was called. **Write `callMeta`-reading conditions defensively.** Since `callMeta` can be `null`, guard the access — `(ctx, meta) => meta?.args?.[0] === id` — or scope the rule to a `target` that only ever gates at a call/construct site. A condition that throws (including a `TypeError` from reading `args` on a `null` `callMeta`) is caught and treated as a non-match, never an implicit allow. @@ -177,6 +177,7 @@ An empty array `[]` is rejected at `addRule()` time. | Function condition at a call/construct gate | Receives `(ctx, { args, target })` — the call's arguments and the concrete target path | | Function condition on a captured reference invoked later | Also receives the real `{ args, target }` of that invocation | | Function condition off the call/construct path | Second argument is `null` (reads, hooks, events, control surface, silent queries) | +| Function condition queried via `global.checkCall` | Receives `(ctx, { args, target })` with the supplied `args` — the call-gate query is not silent | | Function condition returns a Promise / thenable | Non-match; rejection absorbed; `DEBUG_PERMISSION_CONDITION_THENABLE` emitted | | Rule declares `requires` | Second argument carries `principals`; rule does not match unless every principal is current | diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index af14fd6d4..e91cf2924 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -648,13 +648,44 @@ Scoped to the calling module via its context. A module can always introspect its ### `global.*` — Gatable Diagnostics -Cross-module inspection. Can be independently denied with a single rule on `slothlet.permissions.global.**`. +Cross-module inspection. Can be independently denied with a single rule on `slothlet.permissions.global.**`. The one exception is `checkCall`, which is **host-only by default** (see [`checkCall` vs `checkAccess`](#checkcall-vs-checkaccess)). -| Method | Description | -| ------------------------------------ | ------------------------------------------------------------------------------------ | -| `global.checkAccess(caller, target)` | Check if an arbitrary `caller` path is allowed to reach `target`. Returns `boolean`. | -| `global.rulesForPath(path)` | List all rules matching a given target path. | -| `global.rulesByModule(moduleID)` | List all rules owned by a given module. | +| Method | Description | +| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `global.checkAccess(caller, target)` | Silent query: check if an arbitrary `caller` path is allowed to reach `target`. Function conditions receive `null` call metadata; nothing is audited. Returns `boolean`. | +| `global.checkCall(caller, target, args?)` | Call-gate query: would `caller` be allowed to **call** `target` with `args`, judged exactly as the real call gate judges it — audited, `{ args, target }` forwarded to conditions, stale principals resolved. Returns `boolean`, or `Promise` only when a principal had to be resolved. Host-only by default. | +| `global.rulesForPath(path)` | List all rules matching a given target path. | +| `global.rulesByModule(moduleID)` | List all rules owned by a given module. | + +#### `checkCall` vs `checkAccess` + +`checkAccess` is a **silent** query: it resolves the rule set for a caller→target pair and nothing else — no arguments, no audit trail, and a null caller identity is treated as the host. That is the right tool for diagnostics, but it cannot answer the question a trusted boundary layer (such as [`@cldmv/slothlet-vine`](https://github.com/CLDMV/slothlet-vine)) has to answer on the serving side before it forwards a remote module's call: _would the call gate let this caller make this exact call?_ `checkCall` is that question — the call-side twin of [`event.resolveLevel`](EVENTS.md#resolving-a-level-without-subscribing), which answers the same "for a supplied identity" question on the event side. + +```javascript +// Host (serving) side: a remote peer identified as "client.app" wants to call project.files.list("p7"). +const ok = await api.slothlet.permissions.global.checkCall("client.app", "project.files.list", ["p7"]); +if (!ok) throw new Error("refused"); +``` + +What it does differently, point by point: + +- **Call metadata.** Function conditions receive `callMeta = { args, target }` — the same shape the real call gate builds — so a rule that authorizes on the resource named in the call (`(ctx, { args }) => args[0] === ctx.projectId`) answers correctly. `args` defaults to `[]` when omitted; `checkAccess` forwards `null` (see [Second argument: call metadata](PERMISSIONS-CONDITIONS.md#second-argument-call-metadata-callmeta)). +- **Ambient context.** Conditions are evaluated against the current `context.run()` context, as `checkAccess` and `resolveLevel` are. +- **Principals.** A `requires` rule whose principal is stale for the current identity is resolved first and the decision re-evaluated, exactly as a promoted call is (see [Principals](#principals)). That is the **only** case in which `checkCall` returns a `Promise`; when every required principal is current — or no rule requires one — it answers synchronously. Always `await` it if the rule set may carry `requires` rules. +- **The caller is a module without a source file.** The [self-call bypass](#evaluation-order) never applies, and a [module-private](#module-private-exports) (`_`-prefixed) target is **denied** outright — it is not judged by the `permissions.private.host` policy, which is what a null caller identity means to `checkAccess`. A supplied identity is a module, never the host. +- **Audited.** The decision emits the same lifecycle events a real call would — `permission:denied` always, `permission:allowed` / `permission:default` under `audit: "verbose"` — with `via: "checkCall"` in the payload, so a probing peer is visible in the audit trail and distinguishable from a real call. `checkAccess` emits nothing. +- **Disabled enforcement** answers `true`, like `checkAccess`. +- **Host-only.** A built-in rule denies modules the query, exactly as `event.resolveLevel` is kept off modules by the `slothlet.event.**` deny — a module that could ask on another identity's behalf would learn that identity's rule outcomes, and the audit trail would misattribute the probe. Since the built-in targets the exact path, an instance rule on the same exact target outranks it (equal specificity, higher layer), which is how the host grants it to a trusted boundary module: + +```javascript +// Built-in rule registered for every instance: +{ caller: "**", target: "slothlet.permissions.global.checkCall", effect: "deny" } + +// Host grant for a trusted boundary module: +{ caller: "vine.**", target: "slothlet.permissions.global.checkCall", effect: "allow" } +``` + +`checkCall` throws `INVALID_ARGUMENT` for a non-string or empty `caller` / `target`, or an `args` that is neither an array nor `null`/`undefined`. ### `control.*` — Global Toggles (Deny-by-Default) @@ -719,6 +750,8 @@ The `PermissionManager` emits lifecycle events for enforcement decisions: | `permission:allowed` | `{ caller, target, rule, conditionMatched, timestamp }` | A call was explicitly allowed by a rule | `audit: "verbose"` only | | `permission:default` | `{ caller, target, policy, timestamp }` | No rule matched; default policy applied | `audit: "verbose"` only | +A decision reached through [`global.checkCall`](#checkcall-vs-checkaccess) emits the same events with an extra `via: "checkCall"` field in the payload, so an audit consumer can tell a query from the gate of a real call. + The `conditionMatched` field in `permission:allowed` and `permission:denied` payloads is `true` when the winning rule had a `condition` field, `false` otherwise. Subscribe via the lifecycle system: diff --git a/src/lib/builders/api_builder.mjs b/src/lib/builders/api_builder.mjs index 85ad23832..e5b857ede 100644 --- a/src/lib/builders/api_builder.mjs +++ b/src/lib/builders/api_builder.mjs @@ -3101,6 +3101,43 @@ export class ApiBuilder extends ComponentBase { return slothlet.handlers.permissionManager.checkAccess(caller, target, null, null, runtimeContext); }, + /** + * Would `caller` be allowed to CALL `target` with `args`? The call-gate twin of + * `event.resolveLevel` (#508): it evaluates exactly what the real call gate evaluates for a + * supplied caller identity — function conditions receive `{ args, target }`, a stale + * `requires` principal is resolved first (the only case that returns a Promise), the caller + * is treated as a module with no source file (no self-call bypass; a module-private target + * is denied), and audit events emit with `via: "checkCall"`. Conditions are evaluated + * against the ambient `context.run()` context, like `checkAccess`. Answers `true` when + * enforcement is disabled. + * + * Host-only: a built-in rule denies modules `slothlet.permissions.global.checkCall`; the host + * grants it to a trusted boundary layer with an instance rule on that exact target. + * + * @param {string} caller - Caller API path (non-empty). + * @param {string} target - Target API path (non-empty). + * @param {Array<*>} [args=[]] - The arguments of the call being asked about. + * @returns {boolean|Promise} True if the call would be allowed; a Promise only when a stale principal had to be resolved. + * @throws {SlothletError} INVALID_ARGUMENT for a non-string / empty `caller` or `target`, or a non-array `args`. + * @public + * @example + * const ok = await api.slothlet.permissions.global.checkCall("client.app", "project.files.list", ["p1"]); + */ + checkCall: function slothlet_permissions_global_checkCall(caller, target, args) { + // Check if permission manager is available + const permissionManager = slothlet.handlers?.permissionManager; + /* v8 ignore start */ + if (!permissionManager?.checkCall) { + throw new slothlet.SlothletError("PERMISSION_MANAGER_NOT_AVAILABLE", { + validationError: true + }); + } + /* v8 ignore stop */ + + const runtimeContext = slothlet.contextManager?.tryGetContext?.()?.context ?? null; + return slothlet.handlers.permissionManager.checkCall(caller, target, args, runtimeContext); + }, + /** * List all rules that match a given target path. * diff --git a/src/lib/handlers/permission-manager.mjs b/src/lib/handlers/permission-manager.mjs index 73a949554..2033d4d55 100644 --- a/src/lib/handlers/permission-manager.mjs +++ b/src/lib/handlers/permission-manager.mjs @@ -69,6 +69,15 @@ const HOOK_TARGET_TYPES = new Set(["before", "after", "always", "error", "hook"] */ const RULE_LAYER_RANK = Object.freeze({ builtin: 0, manifest: 1, instance: 2, runtime: 3 }); +/** + * Enforcement options for the call-gate query {@link PermissionManager#checkCall} (#508): the supplied + * caller is a module (never the host), the audit payload names the query as its origin, and — as for a + * promoted call whose stale-principal scan has just run — principals count as current under grace. + * @type {Readonly<{ moduleCaller: true, via: "checkCall", principalGrace: true }>} + * @private + */ +const CHECK_CALL_OPTIONS = Object.freeze({ moduleCaller: true, via: "checkCall", principalGrace: true }); + /** * Resolve a rule's precedence layer from an explicit layer or the owning module id. * @param {string|null} layer - Explicit layer ("builtin"|"manifest"|"instance"|"runtime"), or null to derive. @@ -132,10 +141,11 @@ function runtime_sameModuleDir(callerFilePath, targetFilePath) { * authorize on the resource named in the call itself, not just ambient context (#455). * * Provided only at the call and construct enforcement gates — where the invocation's arguments - * exist. Read gating, hook gating, event delivery, the internal `slothlet.*` control surface, and - * silent queries evaluate conditions with `callMeta === null`, so a function condition that reads - * `callMeta.args` must guard for its absence (or the rule must only match targets that always gate - * at a call/construct site). + * exist — and by the call-gate query {@link PermissionManager#checkCall} (#508), which is handed the + * arguments it should evaluate against. Read gating, hook gating, event delivery, the internal + * `slothlet.*` control surface, and silent queries evaluate conditions with `callMeta === null`, so a + * function condition that reads `callMeta.args` must guard for its absence (or the rule must only + * match targets that always gate at a call/construct site). * * @typedef {object} PermissionCallMeta * @property {Array<*>|null} args - Arguments the target leaf was called/constructed with, or null. @@ -390,6 +400,15 @@ export class PermissionManager extends ComponentBase { // grants it to the modules that should define principals, exactly like any other rule. this.addRule({ caller: "**", target: "slothlet.permissions.principal.**", effect: "deny" }, "__builtin__"); + // Call-gate query (#508): `global.checkCall` answers "may caller X call target Y with these args" + // for a SUPPLIED caller identity, exactly like `event.resolveLevel` answers for a supplied + // subscriber — so it is host-only by the same mechanism (a built-in deny), not merely gatable like + // the rest of `global.*`. A module that could ask on another module's behalf would learn that + // module's rule outcomes, and the query's audit trail would attribute the probe to the wrong + // identity. The host grants it to a trusted boundary layer with an instance rule of equal + // specificity (an exact target, `slothlet.permissions.global.checkCall`), which outranks this one. + this.addRule({ caller: "**", target: "slothlet.permissions.global.checkCall", effect: "deny" }, "__builtin__"); + // Built-in hook-management baseline (enforced only when permissions are enabled): modules may // inspect and register hooks (`list`, `on`) but may NOT tamper with other modules' hooks via the // global-effect methods (remove/off/clear, enable/disable, enablePattern/disablePattern/reset). @@ -1328,9 +1347,14 @@ export class PermissionManager extends ComponentBase { * @param {PermissionCallMeta|null} [callMeta=null] - Call/construct metadata (#455): `{ args, target }` * from the invocation, forwarded to function conditions as their second argument. Null for reads, * hooks, the internal control surface, and silent queries. - * @param {{ principalGrace?: boolean }|null} [options=null] - Enforcement options. + * @param {{ principalGrace?: boolean, moduleCaller?: boolean, via?: string }|null} [options=null] - Enforcement options. * @param {boolean} [options.principalGrace=false] - Accept a principal whose epoch is current even if its * `maxAge` has elapsed (#459). Set only by a promoted call re-enforcing right after its resolve. + * @param {boolean} [options.moduleCaller=false] - The caller is a module even though no source file is + * known for it (#508). A module-private target is then denied outright instead of being judged by + * the `permissions.private.host` policy, which is what a null `callerFilePath` otherwise means. + * @param {string} [options.via] - Origin marker added to the audit payload (`via: "checkCall"`), so an + * audit consumer can tell a query from the gate of a real call. * @returns {boolean} True if access is allowed. * @example * if (!pm.enforceAccess("payments.charge", "db.write", "/src/pay.mjs", "/src/db.mjs")) { @@ -1354,16 +1378,96 @@ export class PermissionManager extends ComponentBase { this.#principalGrace = options?.principalGrace === true; let result; try { - result = this.#resolveAccess(callerPath, targetPath, callerFilePath, targetFilePath, runtimeContext, true, callMeta); + result = this.#resolveAccess( + callerPath, + targetPath, + callerFilePath, + targetFilePath, + runtimeContext, + true, + callMeta, + options?.moduleCaller === true + ); } finally { this.#principalGrace = priorGrace; } if (result.event) { - this.#emitAuditEvent(result.event, result.payload); + // The decision record may be the cached entry itself — spread rather than mutate it. + const via = typeof options?.via === "string" ? options.via : null; + this.#emitAuditEvent(result.event, via ? { ...result.payload, via } : result.payload); } return result.allowed; } + /** + * Call-gate query (#508): would `callerPath` be allowed to CALL `targetPath` with `args`? The + * audited, argument-aware twin of {@link checkAccess} — it evaluates exactly what the real call gate + * evaluates, for a supplied caller identity, without invoking anything. + * + * - Function conditions receive `callMeta = { args, target: targetPath }`, the shape the call gate + * builds, so a rule that authorizes on the resource named in the call answers correctly. + * - The caller is a MODULE with no source file: the self-call bypass never applies, and a + * module-private (`_`-prefixed) target is denied outright rather than judged by the + * `permissions.private.host` policy. + * - A `requires` rule whose principal is stale is resolved first and the decision re-evaluated + * with principal grace, mirroring a promoted call (#459). Only then is the result a Promise; the + * fast path (nothing stale) answers synchronously. + * - Audit events emit as for a real call (`permission:denied` always, `allowed`/`default` under + * `audit: "verbose"`), with `via: "checkCall"` in the payload so a probing peer is visible. + * - Disabled enforcement answers `true`, like {@link checkAccess}. + * + * @param {string} callerPath - The supplied caller's api path (non-empty). + * @param {string} targetPath - The target api path being called (non-empty). + * @param {Array<*>} [args=[]] - The arguments of the call being asked about; omit for an argument-less call. + * @param {object|null} [runtimeContext=null] - Per-request ALS context for condition evaluation. + * @returns {boolean|Promise} The gate's decision; a Promise only when a stale principal had to be resolved. + * @throws {SlothletError} INVALID_ARGUMENT when `callerPath` / `targetPath` is not a non-empty string + * or `args` is neither an array nor null/undefined. + * @example + * const ok = pm.checkCall("client.app", "project.files.list", ["p1"], { user: "u1" }); + * if (ok instanceof Promise) await ok; + */ + checkCall(callerPath, targetPath, args = [], runtimeContext = null) { + if (typeof callerPath !== "string" || callerPath.length === 0) { + throw new this.SlothletError("INVALID_ARGUMENT", { + argument: "callerPath", + expected: "a non-empty string", + received: typeof callerPath, + validationError: true + }); + } + if (typeof targetPath !== "string" || targetPath.length === 0) { + throw new this.SlothletError("INVALID_ARGUMENT", { + argument: "targetPath", + expected: "a non-empty string", + received: typeof targetPath, + validationError: true + }); + } + // `null`/`undefined` both mean "no arguments", as on every other optional bag in the api. + const callArgs = args == null ? [] : args; + if (!Array.isArray(callArgs)) { + throw new this.SlothletError("INVALID_ARGUMENT", { + argument: "args", + expected: "an array", + received: typeof callArgs, + validationError: true + }); + } + + const callMeta = { args: callArgs, target: targetPath }; + // Scan-then-enforce, as the promoted call gate does (#459): a stale principal is resolved first, + // and the enforcement after the scan runs with principal grace so a `maxAge` expiring between the + // two clock reads cannot flip the verdict. + const stale = this.stalePrincipals(callerPath, targetPath, null, null, runtimeContext); + if (stale.length > 0) { + return this.resolvePrincipals(stale).then(() => + this.enforceAccess(callerPath, targetPath, null, null, runtimeContext, callMeta, CHECK_CALL_OPTIONS) + ); + } + return this.enforceAccess(callerPath, targetPath, null, null, runtimeContext, callMeta, CHECK_CALL_OPTIONS); + } + /** * Enforce whether a caller may register or fire a hook of `hookType` on `hookPath`. * @@ -1878,10 +1982,13 @@ export class PermissionManager extends ComponentBase { * @param {PermissionCallMeta|null} callMeta - Call/construct metadata (#455) forwarded to function * conditions; `null` off the call/construct path. Never affects caching: a call whose args a * condition reads is a condition-bearing rule, which already bypasses the cache. + * @param {boolean} [moduleCaller=false] - The caller is a module with no known source file (#508), so a + * module-private target is denied instead of judged by the host policy. Decided before the cache, + * so it never affects caching either. * @returns {{ allowed: boolean, event: string|null, payload: object|null, hasConditionalRules?: boolean }} Decision record. * @private */ - #resolveAccess(callerPath, targetPath, callerFilePath, targetFilePath, runtimeContext, useCache, callMeta) { + #resolveAccess(callerPath, targetPath, callerFilePath, targetFilePath, runtimeContext, useCache, callMeta, moduleCaller = false) { // Global toggle: when disabled, everything is allowed (no event to emit). // Exception: slothlet.permissions.control.** is always subject to rule evaluation // regardless of enabled state, so the built-in deny rule protects the toggle surface. @@ -1922,7 +2029,9 @@ export class PermissionManager extends ComponentBase { } // No caller identity = the host (module callers always carry a file path at the // enforcement sites). The host obeys the configured policy — secure default deny. - if (!callerFilePath && this.#privateHost === "allow") { + // A caller declared a module without a file (#508: the call-gate query answers for a + // supplied module identity) is never the host, so the host policy does not apply to it. + if (!callerFilePath && !moduleCaller && this.#privateHost === "allow") { return { allowed: true, event: "permission:private-host-allow", diff --git a/tests/vitests/suites/permissions/permissions-global-check-call.test.vitest.mjs b/tests/vitests/suites/permissions/permissions-global-check-call.test.vitest.mjs new file mode 100644 index 000000000..beeec524a --- /dev/null +++ b/tests/vitests/suites/permissions/permissions-global-check-call.test.vitest.mjs @@ -0,0 +1,442 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/permissions/permissions-global-check-call.test.vitest.mjs + * @Date: 2026-09-28 12:00:00 -07:00 (1790622000) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 12:00:00 -07:00 (1790622000) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview `api.slothlet.permissions.global.checkCall(callerPath, targetPath, args)` (#508) — the + * host-only call-gate query, the call-side twin of `event.resolveLevel`: would a SUPPLIED caller + * identity be allowed to call a target with these arguments, judged exactly as the real call gate + * judges it. + * + * @description + * Exercises: parity with `checkAccess` for unconditional rules; function conditions receiving the + * call's `{ args, target }` (which `checkAccess` never forwards); ambient `context.run()` context; + * the stale-principal path (a Promise, resolved and re-evaluated with grace) versus the fresh path + * (synchronous); a module-private target denied regardless of the host policy; the self-call bypass + * never applying to a file-less caller; audit events emitted with `via: "checkCall"`; disabled + * enforcement answering `true`; host-only gating (a module caller is refused, an exact instance + * grant opens it); and argument validation — across the eager/lazy × async/live matrix. A second + * describe drives the PermissionManager directly for the `moduleCaller` / `via` enforcement options. + * + * @module tests/vitests/suites/permissions/permissions-global-check-call.test.vitest + */ + +import { describe, it, expect, afterEach, vi } from "vitest"; +import slothlet from "@cldmv/slothlet"; +import { PermissionManager } from "#handlers/permission-manager"; +import { SlothletError } from "@cldmv/slothlet/errors"; +import { getMatrixConfigs, TEST_DIRS } from "../../setup/vitest-helper.mjs"; + +const BASE = TEST_DIRS.API_TEST_PERMISSIONS; +const PRIVATE = new URL("../../../../api_tests/api_test_private", import.meta.url).pathname; + +/** + * Let a lifecycle emit's listener notification settle before asserting on what it collected. + * @returns {Promise} + */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + +describe.each(getMatrixConfigs())("Permissions > global.checkCall (#508) > $name", ({ config }) => { + let api; + + afterEach(async () => { + if (api) await api.shutdown(); + api = null; + }); + + /** + * Compose the permissions fixture with the given permission config. + * @param {object} permissions - `config.permissions`. + * @param {string} [base=BASE] - Fixture directory. + * @returns {Promise} Bound api. + */ + const build = async (permissions, base = BASE) => slothlet({ ...config, base, permissions }); + + /** + * Collect the permission audit events an instance emits. + * @param {object} instance - Bound api. + * @returns {Array<{ event: string, payload: object }>} Live list of `{ event, payload }` records. + */ + const collectAudit = (instance) => { + const seen = []; + for (const event of ["permission:denied", "permission:allowed", "permission:default"]) { + instance.slothlet.lifecycle.on(event, (payload) => seen.push({ event, payload })); + } + return seen; + }; + + // ── Parity with checkAccess ────────────────────────────────────────────────── + + it("agrees with checkAccess for rules without conditions, synchronously", async () => { + api = await build({ + defaultPolicy: "allow", + rules: [{ caller: "untrusted.**", target: "admin.**", effect: "deny" }] + }); + const { checkCall, checkAccess } = api.slothlet.permissions.global; + + for (const [caller, target] of [ + ["payments.charge", "db.read"], + ["untrusted.plugin", "admin.manage"], + ["untrusted.plugin", "db.read"] + ]) { + const viaCall = checkCall(caller, target, []); + expect(viaCall, `${caller} → ${target} answers synchronously`).toBeTypeOf("boolean"); + expect(viaCall, `${caller} → ${target} agrees with checkAccess`).toBe(checkAccess(caller, target)); + } + }); + + // ── callMeta: args + concrete target ───────────────────────────────────────── + + it("forwards { args, target } to function conditions — which checkAccess never sees", async () => { + const seen = []; + api = await build({ + defaultPolicy: "deny", + rules: [ + { + caller: "callers.**", + target: "payments.charge.process", + effect: "allow", + condition: (ctx, meta) => { + seen.push(meta); + return meta?.args?.[0] >= 100; + } + } + ] + }); + const { checkCall, checkAccess } = api.slothlet.permissions.global; + + expect(checkCall("callers.paymentsCaller", "payments.charge.process", [100])).toBe(true); + expect(seen.at(-1)).toEqual({ args: [100], target: "payments.charge.process" }); + + // Denied purely because the condition read the supplied args. + expect(checkCall("callers.paymentsCaller", "payments.charge.process", [50])).toBe(false); + expect(seen.at(-1)).toEqual({ args: [50], target: "payments.charge.process" }); + + // Omitted args are an empty argument list, still a real callMeta. + expect(checkCall("callers.paymentsCaller", "payments.charge.process")).toBe(false); + expect(seen.at(-1)).toEqual({ args: [], target: "payments.charge.process" }); + + // checkAccess is the silent query: its function conditions receive null callMeta (unchanged). + expect(checkAccess("callers.paymentsCaller", "payments.charge.process")).toBe(false); + expect(seen.at(-1)).toBeNull(); + }); + + it("callMeta.target is the concrete target so one globbed rule can branch per target", async () => { + api = await build({ + defaultPolicy: "deny", + rules: [ + { + caller: "callers.**", + target: "payments.**", + effect: "allow", + condition: (ctx, meta) => meta?.target === "payments.charge.process" + } + ] + }); + const { checkCall } = api.slothlet.permissions.global; + + expect(checkCall("callers.paymentsCaller", "payments.charge.process", [1])).toBe(true); + expect(checkCall("callers.paymentsCaller", "payments.webhook.handleWebhook", [{ type: "ping" }])).toBe(false); + }); + + // ── Ambient context ────────────────────────────────────────────────────────── + + it("evaluates conditions against the ambient context.run() context, combined with the args", async () => { + api = await build({ + defaultPolicy: "deny", + rules: [ + { + caller: "callers.**", + target: "payments.charge.process", + effect: "allow", + condition: (ctx, meta) => ctx.role === "admin" && meta?.args?.[0] === 100 + } + ] + }); + const { checkCall } = api.slothlet.permissions.global; + const ask = (amount) => checkCall("callers.paymentsCaller", "payments.charge.process", [amount]); + + expect(await api.slothlet.context.run({ role: "admin" }, () => ask(100)), "right role, right args").toBe(true); + expect(await api.slothlet.context.run({ role: "admin" }, () => ask(999)), "right role, wrong args").toBe(false); + expect(await api.slothlet.context.run({ role: "guest" }, () => ask(100)), "wrong role").toBe(false); + expect(ask(100), "no ambient context").toBe(false); + }); + + // ── Principals: stale → Promise, fresh → sync ──────────────────────────────── + + it("resolves a stale required principal first (a Promise) and answers synchronously once it is current", async () => { + api = await build({ + defaultPolicy: "deny", + rules: [ + { + caller: "client.**", + target: "project.files.list", + effect: "allow", + requires: ["roles"], + condition: (ctx, { args, principals }) => principals.roles.projects[args[0]]?.includes("read") === true + } + ] + }); + const resolve = vi.fn(async (user) => ({ projects: user === "u1" ? { p1: ["read"] } : {} })); + api.slothlet.permissions.principal.register("roles", { key: (ctx) => ctx.user, resolve }); + const { checkCall } = api.slothlet.permissions.global; + const ask = (project) => checkCall("client.app", "project.files.list", [project]); + + // First query for this identity: the principal is stale, so the answer is deferred behind its resolve. + let first; + let second; + let third; + await api.slothlet.context.run({ user: "u1" }, async () => { + first = ask("p1"); + expect(first).toBeInstanceOf(Promise); + expect(await first).toBe(true); + // Now current: the fast path answers synchronously, for both verdicts. + second = ask("p1"); + third = ask("p2"); + }); + expect(second).toBe(true); + expect(third).toBe(false); + expect(resolve).toHaveBeenCalledTimes(1); + + // Another identity with no grant: still a Promise (its facts are stale), resolving — not rejecting — to false. + let other; + await api.slothlet.context.run({ user: "u2" }, async () => { + other = ask("p1"); + expect(other).toBeInstanceOf(Promise); + await expect(other).resolves.toBe(false); + }); + expect(resolve).toHaveBeenCalledTimes(2); + }); + + // ── Module-private targets + no self-call bypass ───────────────────────────── + + it("denies a module-private target outright — even under private.host allow, where checkAccess judges as the host", async () => { + api = await build({ defaultPolicy: "allow", rules: [], private: { host: "allow" } }, PRIVATE); + const audit = collectAudit(api); + const { checkCall, checkAccess } = api.slothlet.permissions.global; + + expect(checkCall("reporting.peek", "billing.internals._scale", [100])).toBe(false); + expect(checkCall("reporting.peek", "billing.internals.__rate")).toBe(false); + // The supplied caller is a module without a file: the host policy does not apply to it. The + // silent query has no caller identity at all and IS judged as the host (existing behaviour). + expect(checkAccess("reporting.peek", "billing.internals._scale")).toBe(true); + + await settle(); + expect(audit).toContainEqual({ + event: "permission:denied", + payload: expect.objectContaining({ caller: "reporting.peek", target: "billing.internals._scale", via: "checkCall" }) + }); + }); + + it("denies a module-private target under the default host policy too", async () => { + api = await build({ defaultPolicy: "allow", rules: [] }, PRIVATE); + expect(api.slothlet.permissions.global.checkCall("reporting.peek", "billing.internals._scale", [100])).toBe(false); + }); + + it("never applies the self-call bypass: the supplied caller has no source file", async () => { + api = await build({ defaultPolicy: "deny", rules: [] }); + const { checkCall } = api.slothlet.permissions.global; + + // The real gate lets a same-file call through (callSelf → identity live in self-caller.mjs)... + expect((await api.callers.selfCaller.callSelf()).ok).toBe(true); + // ...but the query cannot know the caller's file, so under a deny default the answer is no. + expect(checkCall("callers.selfCaller.callSelf", "callers.selfCaller.identity", [])).toBe(false); + expect(checkCall("payments.charge.process", "payments.charge.process", [])).toBe(false); + }); + + // ── Audit ──────────────────────────────────────────────────────────────────── + + it("emits the audit lifecycle events as a real call would, tagged via: 'checkCall'", async () => { + api = await build({ + defaultPolicy: "allow", + audit: "verbose", + rules: [ + { caller: "untrusted.**", target: "admin.**", effect: "deny" }, + { caller: "payments.**", target: "db.**", effect: "allow" } + ] + }); + const audit = collectAudit(api); + const { checkCall, checkAccess } = api.slothlet.permissions.global; + + expect(checkCall("untrusted.plugin", "admin.manage", ["x"])).toBe(false); + expect(checkCall("payments.charge", "db.read", [])).toBe(true); + expect(checkCall("cache.store", "widgets.render")).toBe(true); + await settle(); + + expect(audit.map((entry) => entry.event)).toEqual(["permission:denied", "permission:allowed", "permission:default"]); + expect(audit[0].payload).toMatchObject({ + caller: "untrusted.plugin", + target: "admin.manage", + via: "checkCall", + rule: expect.objectContaining({ caller: "untrusted.**", target: "admin.**", effect: "deny" }), + conditionMatched: false + }); + expect(audit[1].payload).toMatchObject({ caller: "payments.charge", target: "db.read", via: "checkCall" }); + expect(audit[2].payload).toMatchObject({ caller: "cache.store", target: "widgets.render", via: "checkCall", policy: "allow" }); + for (const { payload } of audit) expect(payload.timestamp).toBeTypeOf("number"); + + // The silent query stays silent. + checkAccess("untrusted.plugin", "admin.manage"); + checkAccess("payments.charge", "db.read"); + await settle(); + expect(audit).toHaveLength(3); + }); + + it("emits permission:denied under the default audit level and nothing for an allow", async () => { + api = await build({ + defaultPolicy: "allow", + rules: [{ caller: "untrusted.**", target: "admin.**", effect: "deny" }] + }); + const audit = collectAudit(api); + const { checkCall } = api.slothlet.permissions.global; + + expect(checkCall("payments.charge", "db.read")).toBe(true); + expect(checkCall("untrusted.plugin", "admin.manage")).toBe(false); + await settle(); + + expect(audit).toHaveLength(1); + expect(audit[0]).toMatchObject({ event: "permission:denied", payload: { via: "checkCall" } }); + }); + + // ── Disabled enforcement ───────────────────────────────────────────────────── + + it("answers true, silently, when the permission system is disabled", async () => { + // No permissions config → the manager is constructed but not enabled (as for checkAccess). + api = await slothlet({ ...config, base: BASE }); + const audit = collectAudit(api); + expect(api.slothlet.permissions.global.checkCall("untrusted.plugin", "admin.manage", ["x"])).toBe(true); + await settle(); + expect(audit).toHaveLength(0); + await api.shutdown(); + + // Explicitly disabled with a deny default and a deny rule: still true. + api = await build({ + enabled: false, + defaultPolicy: "deny", + rules: [{ caller: "untrusted.**", target: "admin.**", effect: "deny" }] + }); + expect(api.slothlet.permissions.global.checkCall("untrusted.plugin", "admin.manage")).toBe(true); + }); + + // ── Host-only ──────────────────────────────────────────────────────────────── + + it("is host-only: a module caller is refused (PERMISSION_DENIED) by a built-in rule, unlike the gatable checkAccess", async () => { + api = await build({ defaultPolicy: "allow", rules: [] }); + + const call = await api.callers.checkCallCaller.attempt("payments.charge", "db.read", []); + expect(call).toEqual({ ok: false, code: "PERMISSION_DENIED" }); + + // checkAccess carries no built-in deny — under an allow default a module reaches it (unchanged). + const access = await api.callers.checkCallCaller.attemptAccess("payments.charge", "db.read"); + expect(access).toEqual({ ok: true, allowed: true }); + + // The built-in rule is visible through the diagnostics surface, like the control.** deny. + const rules = api.slothlet.permissions.global.rulesForPath("slothlet.permissions.global.checkCall"); + expect( + rules.find((r) => r.caller === "**" && r.effect === "deny" && r.target === "slothlet.permissions.global.checkCall") + ).toBeDefined(); + }); + + it("the host can grant checkCall to a trusted module with an instance rule on the exact target", async () => { + api = await build({ + defaultPolicy: "allow", + rules: [ + { caller: "callers.checkCallCaller.**", target: "slothlet.permissions.global.checkCall", effect: "allow" }, + { caller: "untrusted.**", target: "admin.**", effect: "deny" } + ] + }); + + expect(await api.callers.checkCallCaller.attempt("payments.charge", "db.read", [])).toEqual({ ok: true, allowed: true }); + expect(await api.callers.checkCallCaller.attempt("untrusted.plugin", "admin.manage", [])).toEqual({ ok: true, allowed: false }); + }); + + // ── Validation ─────────────────────────────────────────────────────────────── + + it("throws INVALID_ARGUMENT for a non-string or empty caller/target and a non-array args", async () => { + api = await build({ defaultPolicy: "allow", rules: [] }); + const { checkCall } = api.slothlet.permissions.global; + + expect(() => checkCall(7, "db.read", [])).toThrow(/INVALID_ARGUMENT/); + expect(() => checkCall("", "db.read", [])).toThrow(/INVALID_ARGUMENT/); + expect(() => checkCall("payments.charge", null, [])).toThrow(/INVALID_ARGUMENT/); + expect(() => checkCall("payments.charge", "", [])).toThrow(/INVALID_ARGUMENT/); + expect(() => checkCall("payments.charge", "db.read", "x")).toThrow(/INVALID_ARGUMENT/); + expect(() => checkCall("payments.charge", "db.read", { 0: "x" })).toThrow(/INVALID_ARGUMENT/); + + // Omitted, undefined, and null args all mean "no arguments". + expect(checkCall("payments.charge", "db.read")).toBe(true); + expect(checkCall("payments.charge", "db.read", undefined)).toBe(true); + expect(checkCall("payments.charge", "db.read", null)).toBe(true); + }); +}); + +describe("PermissionManager > checkCall enforcement options (#508)", () => { + let manager = null; + + afterEach(async () => { + if (manager) await manager.shutdown(); + manager = null; + }); + + /** + * Build a standalone manager. + * @param {object[]} rules - Config rules. + * @param {object} [extra] - Extra permission config. + * @returns {{ manager: PermissionManager, lifecycle: { emit: import("vitest").Mock } }} The manager and its lifecycle spy. + */ + const makeManager = (rules = [], extra = {}) => { + const lifecycle = { emit: vi.fn() }; + const instance = new PermissionManager({ + config: { permissions: { enabled: true, defaultPolicy: "deny", rules, ...extra } }, + handlers: { lifecycle }, + debug: () => {}, + SlothletError + }); + return { manager: instance, lifecycle }; + }; + + it("enforceAccess({ moduleCaller: true }) denies a private target the host policy would allow, and tags the audit with via", () => { + let lifecycle; + ({ manager, lifecycle } = makeManager([], { private: { host: "allow" } })); + + // No caller file at all = the host, which the policy opens... + expect(manager.enforceAccess("reporting.peek", "billing.internals._scale", null, null, null, null)).toBe(true); + expect(lifecycle.emit).not.toHaveBeenCalledWith("permission:denied", expect.anything()); + // ...but a caller declared a file-less MODULE is never the host. + expect( + manager.enforceAccess("reporting.peek", "billing.internals._scale", null, null, null, null, { moduleCaller: true, via: "probe" }) + ).toBe(false); + expect(lifecycle.emit).toHaveBeenCalledWith( + "permission:denied", + expect.objectContaining({ caller: "reporting.peek", target: "billing.internals._scale", via: "probe" }) + ); + }); + + it("does not mutate a cached decision record when tagging the audit payload", () => { + let lifecycle; + ({ manager, lifecycle } = makeManager([{ caller: "a.**", target: "b.**", effect: "deny" }])); + + // First enforcement caches the decision; the tagged emit must spread, not write `via` into the cache. + expect(manager.checkCall("a.x", "b.y", [])).toBe(false); + expect(lifecycle.emit).toHaveBeenLastCalledWith("permission:denied", expect.objectContaining({ via: "checkCall" })); + expect(manager.enforceAccess("a.x", "b.y")).toBe(false); + expect(lifecycle.emit).toHaveBeenLastCalledWith("permission:denied", expect.not.objectContaining({ via: expect.anything() })); + }); + + it("answers a private target synchronously even when a requires rule would otherwise need a resolve", () => { + ({ manager } = makeManager([{ caller: "**", target: "**", effect: "allow", requires: ["roles"] }])); + manager.registerPrincipal("roles", { key: (ctx) => ctx.user, resolve: async () => ({}) }); + + // Privacy is decided before rules, so the stale scan short-circuits and no Promise is produced. + expect(manager.checkCall("reporting.peek", "billing.internals._scale", [], { user: "u1" })).toBe(false); + }); +}); From 7654b712ad28273e2a9136d533b14dfe6de05ca4 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 28 Sep 2026 17:05:22 -0700 Subject: [PATCH 16/49] fix(events): evaluate event-rule conditions against the user context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A conditional event rule's condition received the whole async-context store ({ instanceID, context: { … }, currentWrapper, … }), while a call rule's condition receives the user context that context.run() set. The same condition therefore behaved differently depending on which rule type it was attached to: an object condition like { tenant: "a" } never deep-matched on an event rule, and a function condition had to read ctx.context.* instead of ctx.* — contrary to docs/EVENTS.md, which promises event rules take the same condition shape as permission rules. Both event-side resolution sites in EventManager — resolveLevel() and the per-emit re-resolution of a subscriber — now pass tryGetContext()?.context, matching every call-side site in api_builder. The requires-principal path reads the same runtime context, so principal key resolvers on event rules now see the user context too. Behavior change: an event-rule condition that worked around the bug by reading ctx.context.* must now read ctx.* directly. The event-manager unit test's tryGetContext mock returned a bare user context in place of the store; it now returns the real store shape and keeps the same expectation. New tests cover object and function conditions through resolveLevel, parity with an identical call-rule condition checked through global.checkAccess in the same scope, and the per-emit path under the emitter's context.run(). Fixes #511 --- docs/EVENTS.md | 2 +- src/lib/handlers/event-manager.mjs | 7 +- .../events/event-manager-unit.test.vitest.mjs | 3 +- ...ent-rule-condition-context.test.vitest.mjs | 98 +++++++++++++++++++ 4 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 tests/vitests/suites/permissions/event-rule-condition-context.test.vitest.mjs diff --git a/docs/EVENTS.md b/docs/EVENTS.md index 60049fa65..0d9bde84f 100644 --- a/docs/EVENTS.md +++ b/docs/EVENTS.md @@ -104,7 +104,7 @@ const api = await slothlet({ - `caller` — a glob matched against the **subscriber's** api path. - `event` — a glob matched against the **event name**. - `effect` — `deny` | `notify` | `allow`. -- `condition` — optional, the same condition shape permission rules accept (see [PERMISSIONS-CONDITIONS.md](PERMISSIONS-CONDITIONS.md)); a subscriber's level is re-resolved per emit when any event rule is conditional. +- `condition` — optional, the same condition shape permission rules accept (see [PERMISSIONS-CONDITIONS.md](PERMISSIONS-CONDITIONS.md)), evaluated against the same `ctx`: the `context.run()` context in effect where the level is resolved (the caller of `resolveLevel`, or the emitter at emit time). Function conditions receive `null` call metadata — an emit has no call arguments. A subscriber's level is re-resolved per emit when any event rule is conditional. `default` overrides the built-in base level (`notify`) for subscribers that match no rule. diff --git a/src/lib/handlers/event-manager.mjs b/src/lib/handlers/event-manager.mjs index a4ac6674e..b47a56253 100644 --- a/src/lib/handlers/event-manager.mjs +++ b/src/lib/handlers/event-manager.mjs @@ -254,7 +254,9 @@ export class EventManager extends ComponentBase { const pm = this.#permissions; // No permission manager → ungated (full payload), matching #levelFor and on(). if (!pm) return "allow"; - const runtimeContext = this.slothlet.contextManager?.tryGetContext?.() ?? null; + // The USER context (`context.run()`'s), not the whole async-context store — event-rule conditions + // see the same `ctx` as call-rule conditions (#511). + const runtimeContext = this.slothlet.contextManager?.tryGetContext?.()?.context ?? null; return pm.resolveEventLevel(subscriberPath, event, runtimeContext); } @@ -279,7 +281,8 @@ export class EventManager extends ComponentBase { return sub.level; } - const runtimeContext = this.slothlet.contextManager?.tryGetContext?.() ?? null; + // The emitter's USER context, as in resolveLevel (#511). + const runtimeContext = this.slothlet.contextManager?.tryGetContext?.()?.context ?? null; const level = pm.resolveEventLevel(sub.subscriberPath, event, runtimeContext); // Only memoize the stable (non-conditional) case; conditional levels are recomputed each emit. sub.level = conditional ? null : level; diff --git a/tests/vitests/suites/events/event-manager-unit.test.vitest.mjs b/tests/vitests/suites/events/event-manager-unit.test.vitest.mjs index c19fd1251..fab89d2bb 100644 --- a/tests/vitests/suites/events/event-manager-unit.test.vitest.mjs +++ b/tests/vitests/suites/events/event-manager-unit.test.vitest.mjs @@ -298,7 +298,8 @@ describe("EventManager.resolveLevel — validation and delegation", () => { return subscriberPath === "reporting.dash" ? "allow" : "notify"; }); const slothlet = makeSlothlet({ pm }); - slothlet.contextManager.tryGetContext = () => ({ tenant: "acme" }); + // tryGetContext() returns the async-context STORE; its `context` is the user context a condition sees (#511). + slothlet.contextManager.tryGetContext = () => ({ instanceID: "inst-1", context: { tenant: "acme" } }); const em = new EventManager(slothlet); expect(em.resolveLevel("reporting.dash", "orders.created")).toBe("allow"); expect(em.resolveLevel("other.mod", "orders.created")).toBe("notify"); diff --git a/tests/vitests/suites/permissions/event-rule-condition-context.test.vitest.mjs b/tests/vitests/suites/permissions/event-rule-condition-context.test.vitest.mjs new file mode 100644 index 000000000..b8e38f3d6 --- /dev/null +++ b/tests/vitests/suites/permissions/event-rule-condition-context.test.vitest.mjs @@ -0,0 +1,98 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/permissions/event-rule-condition-context.test.vitest.mjs + * + * Event-rule conditions see the same `ctx` as call-rule conditions (#511): the user context set by + * `context.run()`, not the whole async-context store. Covers both event-side resolution sites — the + * host `event.resolveLevel()` query and the per-emit re-resolution of a subscriber when a conditional + * event rule is present — for object (deep-match) and function conditions, and pins parity with a + * call rule carrying the identical condition, checked through `global.checkAccess` in the same scope. + */ +import { describe, it, expect, afterEach } from "vitest"; +import slothlet from "@cldmv/slothlet"; +import { TEST_DIRS } from "../../setup/vitest-helper.mjs"; + +const BASE = TEST_DIRS.API_TEST_EVENTS; + +describe("event-rule conditions receive the user context (#511)", () => { + let api; + + afterEach(async () => { + if (api) await api.shutdown(); + api = null; + }); + + it("an object condition deep-matches the context.run() context in event.resolveLevel", async () => { + api = await slothlet({ + base: BASE, + permissions: { + defaultPolicy: "allow", + events: { + default: "notify", + rules: [{ caller: "remote.renderer", event: "cond.*", effect: "allow", condition: { tenant: "tenant-a" } }] + } + } + }); + const inScope = await api.slothlet.context.run({ tenant: "tenant-a" }, () => + api.slothlet.event.resolveLevel("remote.renderer", "cond.data") + ); + expect(inScope).toBe("allow"); + const otherTenant = await api.slothlet.context.run({ tenant: "tenant-b" }, () => + api.slothlet.event.resolveLevel("remote.renderer", "cond.data") + ); + expect(otherTenant).toBe("notify"); + expect(api.slothlet.event.resolveLevel("remote.renderer", "cond.data")).toBe("notify"); + }); + + it("a function condition reads ctx.actor directly, exactly as the same condition on a call rule does", async () => { + const condition = (ctx) => ctx?.actor?.id === "u1"; + api = await slothlet({ + base: BASE, + permissions: { + defaultPolicy: "deny", + rules: [{ caller: "remote.renderer", target: "probe.**", effect: "allow", condition }], + events: { + default: "notify", + rules: [{ caller: "remote.renderer", event: "cond.*", effect: "allow", condition }] + } + } + }); + const asU1 = await api.slothlet.context.run({ actor: { id: "u1" } }, () => ({ + event: api.slothlet.event.resolveLevel("remote.renderer", "cond.data"), + call: api.slothlet.permissions.global.checkAccess("remote.renderer", "probe.anything") + })); + expect(asU1).toEqual({ event: "allow", call: true }); + const asU2 = await api.slothlet.context.run({ actor: { id: "u2" } }, () => ({ + event: api.slothlet.event.resolveLevel("remote.renderer", "cond.data"), + call: api.slothlet.permissions.global.checkAccess("remote.renderer", "probe.anything") + })); + expect(asU2).toEqual({ event: "notify", call: false }); + }); + + it("the per-emit re-resolution of a subscriber sees the emitter's context.run() context", async () => { + api = await slothlet({ + base: BASE, + permissions: { + defaultPolicy: "allow", + events: { + default: "notify", + rules: [{ caller: "subscriber.**", event: "cond.*", effect: "allow", condition: { tenant: "tenant-a" } }] + } + } + }); + // Subscribed outside any context: the conditional rule does not match, so the grant is the default. + expect(await api.subscriber.subscribe("cond.data")).toBe("notify"); + api.subscriber.drain(); + + // Emitted inside the matching context: the level is re-resolved per emit (the pool is conditional), + // the condition sees `{ tenant: "tenant-a" }`, and the payload is delivered. + await api.slothlet.context.run({ tenant: "tenant-a" }, () => api.slothlet.event.emit("cond.data", { n: 1 })); + // Emitted inside a non-matching context: trigger-only. + await api.slothlet.context.run({ tenant: "tenant-b" }, () => api.slothlet.event.emit("cond.data", { n: 2 })); + + const deliveries = await api.subscriber.drain(); + expect(deliveries).toHaveLength(2); + expect(deliveries[0].payload).toEqual({ n: 1 }); + expect(deliveries[1].payload).not.toEqual({ n: 2 }); + }); +}); From cad818dd243036149977d19e33d134f112613eb1 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 28 Sep 2026 13:51:18 -0700 Subject: [PATCH 17/49] fix(typescript): honour module, strict, compilerOptions and sourcemap options normalizeTypeScript() dropped `module`, `strict` and `compilerOptions` from the object form, so strict mode's loader always read undefined and transformTypeScriptStrict() fell back to fixed defaults. They are now carried through normalization, type-validated alongside `target` and `sourcemap` (strings / booleans / a plain object; anything else raises INVALID_CONFIG), and documented in docs/TYPESCRIPT.md as strict-mode options. `compilerOptions` is accepted in tsconfig.json form and converted with ts.convertCompilerOptionsFromJson, so string values such as `module: "commonjs"` work; an unknown option or invalid value raises INVALID_CONFIG. `sourcemap` had no effect: fast mode passed it to esbuild.transform() but returned only result.code, discarding the separate result.map, and strict mode never received it. Both paths now emit an inline source map whose `sources` entry is the leaf's absolute path (esbuild `sourcefile`; tsc's relative source rewritten), so `--enable-source-maps` maps stack frames from the .slothlet-cache copy back to the .ts source. The cache file name hashes the transpiled code, which now includes the map, so mapped and unmapped output never share a cache file. The new strict-mode options suite boots strict mode per test and is added to SOLO_RUN_PATTERNS next to typescript-strict-mode. Fixes #499 --- docs/TYPESCRIPT.md | 49 ++++- src/lib/helpers/config.mjs | 50 ++++- src/lib/processors/loader.mjs | 3 +- src/lib/processors/typescript.mjs | 76 +++++++- tests/vitests/run-all-vitest.mjs | 3 + ...pescript-options-sourcemap.test.vitest.mjs | 165 +++++++++++++++++ .../typescript-options-strict.test.vitest.mjs | 172 ++++++++++++++++++ ...escript-options-validation.test.vitest.mjs | 110 +++++++++++ types/src/lib/helpers/config.d.mts | 1 + types/src/lib/helpers/config.d.mts.map | 2 +- types/src/lib/processors/loader.d.mts.map | 2 +- types/src/lib/processors/typescript.d.mts | 11 +- types/src/lib/processors/typescript.d.mts.map | 2 +- 13 files changed, 630 insertions(+), 16 deletions(-) create mode 100644 tests/vitests/suites/typescript/typescript-options-sourcemap.test.vitest.mjs create mode 100644 tests/vitests/suites/typescript/typescript-options-strict.test.vitest.mjs create mode 100644 tests/vitests/suites/typescript/typescript-options-validation.test.vitest.mjs diff --git a/docs/TYPESCRIPT.md b/docs/TYPESCRIPT.md index 0d2a76745..1c7f0e999 100644 --- a/docs/TYPESCRIPT.md +++ b/docs/TYPESCRIPT.md @@ -146,7 +146,10 @@ typescript: "strict"; // Strict mode (tsc) typescript: { mode: "fast" | "strict", // Required. Defaults to "fast" if omitted target: "es2020", // ECMAScript target. Default: "es2020" - sourcemap: false, // Generate source maps. Default: false + sourcemap: false, // Inline source maps. Default: false + module: "esnext", // Strict mode only. Module kind for the type check. Default: "esnext" + strict: true, // Strict mode only. tsc's strict checking. Default: true + compilerOptions: {}, // Strict mode only. Extra tsc compiler options (tsconfig.json form) types: { // Type generation. Strict mode only. output: "./types/api.d.ts", // Required. Path to write the .d.ts file interfaceName: "MyAPI" // Required. Name of the generated interface @@ -160,7 +163,35 @@ Accepts any valid ECMAScript target string: `"es2015"`, `"es2017"`, `"es2018"`, #### `sourcemap` -When `true`, source maps are generated and inlined. Useful for debugging in development. Default: `false`. +When `true`, each transpiled module carries an inline source map whose `sources` entry is the absolute path of the `.ts` file. Transpiled modules are loaded from a cache directory (`.slothlet-cache/`), so without a map, stack frames point at the cached copy; with a map and Node's `--enable-source-maps` flag (or any tool that reads inline maps, such as a debugger), they point at the original `.ts` source. Applies to both fast and strict mode. Default: `false`. + +```bash +node --enable-source-maps app.mjs +``` + +#### `module` + +Strict mode only. The module kind tsc checks and emits against: `"esnext"`, `"es2022"`, `"es2020"`, `"es2015"`, `"node16"`, `"nodenext"`, and so on. Default: `"esnext"`. Slothlet loads the output as an ES module, so pick an ES module kind; a CommonJS module kind is useful only to have the type check flag ESM-only syntax such as `import.meta`. + +#### `strict` + +Strict mode only. Sets tsc's `strict` flag (`noImplicitAny`, `strictNullChecks`, and the rest of the strict family). Default: `true`. Set `strict: false` to type-check with the strict family off. + +#### `compilerOptions` + +Strict mode only. Extra tsc compiler options, written as in the `compilerOptions` block of a `tsconfig.json` (string values such as `module: "commonjs"` are accepted), applied on top of the options above. Relative paths resolve against the current working directory. An unknown option or an invalid value throws `INVALID_CONFIG`. + +```javascript +typescript: { + mode: "strict", + types: { output: "./types/api.d.ts", interfaceName: "MyAPI" }, + compilerOptions: { noUnusedLocals: true, noUnusedParameters: true } +} +``` + +#### Validation + +`target` and `module` must be strings, `sourcemap` and `strict` booleans, and `compilerOptions` a plain object. Any other value throws `INVALID_CONFIG` when `slothlet()` is called. --- @@ -203,6 +234,20 @@ const api = await slothlet({ Strict mode is slower than fast mode due to full compilation. It is well-suited for production validation, CI checks, or anywhere you want to catch type errors at startup. +How strict the check is can be tuned with [`strict`](#strict), [`module`](#module), and [`compilerOptions`](#compileroptions): + +```javascript +const api = await slothlet({ + dir: "./api", + typescript: { + mode: "strict", + strict: true, + compilerOptions: { noUnusedLocals: true }, + types: { output: "./types/api.d.ts", interfaceName: "MyAPI" } + } +}); +``` + --- ## Type Generation (.d.ts) diff --git a/src/lib/helpers/config.mjs b/src/lib/helpers/config.mjs index ffab01551..b1805d687 100644 --- a/src/lib/helpers/config.mjs +++ b/src/lib/helpers/config.mjs @@ -736,12 +736,25 @@ export class Config extends ComponentBase { // Object configuration if (typeof typescript === "object") { const mode = typescript.mode === "strict" ? "strict" : "fast"; + this.#validateTypeScriptOption("target", typescript.target, "string", 'a string such as "es2020"'); + this.#validateTypeScriptOption("sourcemap", typescript.sourcemap, "boolean", "a boolean"); + this.#validateTypeScriptOption("module", typescript.module, "string", 'a string such as "esnext"'); + this.#validateTypeScriptOption("strict", typescript.strict, "boolean", "a boolean"); + this.#validateTypeScriptOption( + "compilerOptions", + typescript.compilerOptions, + "plain-object", + "a plain object of tsconfig.json compilerOptions" + ); return { enabled: true, mode, types: typescript.types || null, target: typescript.target || "es2020", - sourcemap: typescript.sourcemap || false + sourcemap: typescript.sourcemap || false, + module: typescript.module ?? null, + strict: typescript.strict ?? null, + compilerOptions: typescript.compilerOptions ?? null }; } @@ -749,6 +762,41 @@ export class Config extends ComponentBase { return null; } + /** + * Validate the type of one `typescript` object-form option. `undefined` and `null` mean + * "not set" and always pass. + * @param {string} name - Option name under `typescript` (e.g. `"strict"`). + * @param {unknown} value - The raw option value. + * @param {"string"|"boolean"|"plain-object"} kind - Required kind of value. + * @param {string} expected - Human-readable description of the accepted values. + * @returns {void} + * @throws {SlothletError} INVALID_CONFIG when the value is set and is not of the required kind. + * @private + */ + #validateTypeScriptOption(name, value, kind, expected) { + if (value === undefined || value === null) return; + let valid; + if (kind === "plain-object") { + const proto = typeof value === "object" ? Object.getPrototypeOf(value) : undefined; + valid = typeof value === "object" && (proto === Object.prototype || proto === null); + } else { + valid = typeof value === kind; + } + if (valid) return; + throw new this.SlothletError( + "INVALID_CONFIG", + { + option: `typescript.${name}`, + value: Array.isArray(value) ? "array" : typeof value === "object" ? (value.constructor?.name ?? "object") : typeof value, + expected, + hint: "HINT_INVALID_CONFIG", + validationError: true + }, + null, + { validationError: true } + ); + } + /** * Normalize env snapshot configuration. * diff --git a/src/lib/processors/loader.mjs b/src/lib/processors/loader.mjs index 2e9fb26c3..fe35e5a6b 100644 --- a/src/lib/processors/loader.mjs +++ b/src/lib/processors/loader.mjs @@ -278,7 +278,8 @@ export class Loader extends ComponentBase { module: typescriptConfig.module, strict: typescriptConfig.strict, typeDefinitionPath: typescriptConfig.types.output, - compilerOptions: typescriptConfig.compilerOptions + compilerOptions: typescriptConfig.compilerOptions, + sourcemap: typescriptConfig.sourcemap }); // Check for type errors if (result.diagnostics && result.diagnostics.length > 0) { diff --git a/src/lib/processors/typescript.mjs b/src/lib/processors/typescript.mjs index 6f5cee6bf..774d0664b 100644 --- a/src/lib/processors/typescript.mjs +++ b/src/lib/processors/typescript.mjs @@ -77,7 +77,8 @@ async function getTypeScript() { * @param {object} [options={}] - esbuild transform options * @param {string} [options.target] - ECMAScript target version (default: "es2020") * @param {string} [options.format] - Module format (default: "esm") - * @param {boolean} [options.sourcemap] - Generate source maps (default: false) + * @param {boolean} [options.sourcemap] - Append an inline source map whose `sources` names the + * absolute path of `filePath`, so the cached output maps back to the `.ts` source (default: false) * @returns {Promise} Transformed JavaScript code * @throws {SlothletError} If transformation fails * @public @@ -85,13 +86,17 @@ async function getTypeScript() { export async function transformTypeScript(filePath, options = {}) { const esbuild = await getEsbuild(); // Lazy load - only when actually needed const code = fs.readFileSync(filePath, "utf8"); + const { sourcemap, ...rest } = options; const result = await esbuild.transform(code, { loader: "ts", - format: options.format || "esm", - target: options.target || "es2020", - sourcemap: options.sourcemap || false, - ...options + format: "esm", + target: "es2020", + ...rest, + // The transpiled output is loaded from a cache file, not from `filePath`, so the map is + // inlined (a separate `result.map` would be discarded) and names the source by its + // absolute path so it resolves from the cache directory. + ...(sourcemap && { sourcemap: "inline", sourcefile: path.resolve(filePath) }) }); return result.code; @@ -842,7 +847,13 @@ async function writeTransformedGraph(absolutePath, code, cacheDir, transform) { * @param {boolean} [options.strict] - Enable strict type checking (default: true) * @param {boolean} [options.skipTypeCheck] - Skip type checking and only transform (default: false) * @param {string} [options.typeDefinitionPath] - Path to .d.ts file for type checking + * @param {boolean} [options.sourcemap] - Append an inline source map whose `sources` names the + * absolute path of `filePath` (default: false) + * @param {object} [options.compilerOptions] - Extra compiler options in tsconfig.json form + * (`{ noUnusedLocals: true, module: "commonjs" }`), applied over the options above. Relative + * paths resolve against the current working directory. * @returns {Promise<{code: string, diagnostics: object[]}>} Transformed code and type diagnostics + * @throws {SlothletError} INVALID_CONFIG when `options.compilerOptions` holds an unknown option or an invalid value * @throws {SlothletError} If transformation fails * @public */ @@ -897,7 +908,7 @@ export async function transformTypeScriptStrict(filePath, options = {}) { typeRoots: [path.dirname(options.typeDefinitionPath)], types: [path.basename(options.typeDefinitionPath, ".d.ts")] }), - ...options.compilerOptions + ...convertCompilerOptions(ts, options.compilerOptions) }; // Perform type checking using Program API if not skipped @@ -927,16 +938,65 @@ export async function transformTypeScriptStrict(filePath, options = {}) { // Transform using transpileModule (fast, doesn't require full type checking) const result = ts.transpileModule(code, { - compilerOptions, + compilerOptions: options.sourcemap + ? { ...compilerOptions, sourceMap: false, inlineSourceMap: true, inlineSources: true } + : compilerOptions, fileName: filePath }); return { - code: result.outputText, + code: options.sourcemap ? withAbsoluteMapSource(result.outputText, path.resolve(filePath)) : result.outputText, diagnostics }; } +/** + * Convert user-supplied compiler options from tsconfig.json form (string enum values such as + * `module: "commonjs"`) to the form the compiler API takes. + * @param {object} ts - TypeScript module instance + * @param {object|null|undefined} compilerOptions - Options in tsconfig.json form + * @returns {object} Converted options (empty when none were given) + * @throws {SlothletError} INVALID_CONFIG when an option is unknown or has an invalid value + * @private + */ +function convertCompilerOptions(ts, compilerOptions) { + if (!compilerOptions) return {}; + const { options, errors } = ts.convertCompilerOptionsFromJson(compilerOptions, process.cwd()); + if (errors.length > 0) { + throw new SlothletError( + "INVALID_CONFIG", + { + option: "typescript.compilerOptions", + value: errors.map((error) => ts.flattenDiagnosticMessageText(error.messageText, " ")).join(" "), + expected: "valid tsconfig.json compilerOptions", + hint: "HINT_INVALID_CONFIG", + validationError: true + }, + null, + { validationError: true } + ); + } + return options; +} + +/** + * Point the inline source map tsc appended to `code` at the source's absolute path. tsc names the + * source relative to the output file, which does not resolve from the cache directory the output + * is loaded from. + * @param {string} code - tsc output ending in an inline `sourceMappingURL` comment + * @param {string} absolutePath - Absolute path of the `.ts` source + * @returns {string} `code` with the map's `sources` replaced by `[absolutePath]` + * @private + */ +function withAbsoluteMapSource(code, absolutePath) { + const marker = "//# sourceMappingURL=data:application/json;base64,"; + const index = code.lastIndexOf(marker); + const map = JSON.parse(Buffer.from(code.slice(index + marker.length).trim(), "base64").toString("utf8")); + map.sources = [absolutePath]; + delete map.sourceRoot; + return code.slice(0, index) + marker + Buffer.from(JSON.stringify(map), "utf8").toString("base64"); +} + /** * Format TypeScript diagnostics into readable error messages * @param {object[]} diagnostics - TypeScript diagnostic objects diff --git a/tests/vitests/run-all-vitest.mjs b/tests/vitests/run-all-vitest.mjs index e141834db..936fa4a94 100644 --- a/tests/vitests/run-all-vitest.mjs +++ b/tests/vitests/run-all-vitest.mjs @@ -135,6 +135,9 @@ const SOLO_RUN_PATTERNS = [ // the other workers and exceeds vitest's 10s hook timeout (passes 13/13 in isolation), so it // runs solo like the other resource-heavy files. "tests/vitests/suites/typescript/typescript-strict-mode.test.vitest.mjs", + // typescript-options-strict boots strict mode (a forked tsc type-generation pass plus the type + // check) once per test, for the same reason as typescript-strict-mode above. + "tests/vitests/suites/typescript/typescript-options-strict.test.vitest.mjs", // typegen-programmatic runs generateTypes per test — two `ts.Program`s (checker + declaration // emit) plus a `tsc` compile of the output in the acceptance check. Under a full parallel run // those tsc passes are starved by the other workers and blow past the test timeout (each takes diff --git a/tests/vitests/suites/typescript/typescript-options-sourcemap.test.vitest.mjs b/tests/vitests/suites/typescript/typescript-options-sourcemap.test.vitest.mjs new file mode 100644 index 000000000..cb95d854e --- /dev/null +++ b/tests/vitests/suites/typescript/typescript-options-sourcemap.test.vitest.mjs @@ -0,0 +1,165 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/typescript/typescript-options-sourcemap.test.vitest.mjs + * @Date: 2026-09-28T00:00:00-07:00 (1790578800) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:00:00 -07:00 (1790578800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview The `typescript.sourcemap` option (#499). + * + * @description + * `sourcemap: true` must produce transpiled output that carries an INLINE source map whose + * `sources` names the original `.ts` file by its absolute path — in fast mode (esbuild) and in + * strict mode's transform path (tsc) — so that `node --enable-source-maps` maps stack frames + * from the `.slothlet-cache/` copy back to the `.ts` source. With `sourcemap` off, no map is + * emitted, and the two outputs hash to different cache files so one is never served for the other. + * + * @module tests/vitests/suites/typescript/typescript-options-sourcemap.test.vitest + */ + +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { writeFile, rm, mkdir } from "node:fs/promises"; +import { transformTypeScript, transformTypeScriptStrict, writeTransformedToCache } from "@cldmv/slothlet/processors/typescript"; +import { writeFileSync } from "node:fs"; +import { makeTestTmpDir } from "../../setup/test-fixtures-tmp.mjs"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, "../../../.."); + +const INLINE_MAP_RE = /\/\/# sourceMappingURL=data:application\/json;base64,([A-Za-z0-9+/=]+)\s*$/; + +/** + * Decode the inline source map appended to transpiled output. + * @param {string} code - Transpiled JavaScript. + * @returns {object|null} The parsed source map, or `null` when none is inlined. + */ +function inlineMap(code) { + const match = INLINE_MAP_RE.exec(code); + return match ? JSON.parse(Buffer.from(match[1], "base64").toString("utf8")) : null; +} + +const LEAF_SOURCE = [ + "export function boom(label: string): never {", + "\tconst message: string = `boom-499:${label}`;", + "\tthrow new Error(message);", + "}", + "" +].join("\n"); + +let root; +let leafPath; + +beforeAll(async () => { + root = await makeTestTmpDir("ts-options-sourcemap"); + leafPath = path.join(root, "api", "boom.ts"); + await mkdir(path.dirname(leafPath), { recursive: true }); + await writeFile(leafPath, LEAF_SOURCE, "utf8"); +}); + +afterAll(async () => { + if (root) await rm(root, { recursive: true, force: true }); +}); + +describe("fast mode (esbuild) sourcemap", () => { + it("emits an inline source map naming the absolute .ts path when sourcemap is true", async () => { + const code = await transformTypeScript(leafPath, { sourcemap: true }); + const map = inlineMap(code); + expect(map).not.toBeNull(); + expect(map.sources).toEqual([leafPath]); + }); + + it("emits no source map when sourcemap is false or omitted", async () => { + expect(inlineMap(await transformTypeScript(leafPath, { sourcemap: false }))).toBeNull(); + expect(inlineMap(await transformTypeScript(leafPath))).toBeNull(); + }); +}); + +describe("strict mode (tsc) sourcemap", () => { + it("emits an inline source map naming the absolute .ts path when sourcemap is true", async () => { + const { code } = await transformTypeScriptStrict(leafPath, { sourcemap: true, skipTypeCheck: true }); + const map = inlineMap(code); + expect(map).not.toBeNull(); + expect(map.sources).toEqual([leafPath]); + }); + + it("emits no source map when sourcemap is omitted", async () => { + const { code } = await transformTypeScriptStrict(leafPath, { skipTypeCheck: true }); + expect(inlineMap(code)).toBeNull(); + }); +}); + +describe("transform cache keys", () => { + it("writes mapped and unmapped output to different cache files", async () => { + const instanceID = `ts-options-sourcemap-${process.pid}`; + const withMap = await transformTypeScript(leafPath, { sourcemap: true }); + const withoutMap = await transformTypeScript(leafPath, { sourcemap: false }); + const a = await writeTransformedToCache(leafPath, withMap, instanceID); + const b = await writeTransformedToCache(leafPath, withoutMap, instanceID); + try { + expect(a.url).not.toBe(b.url); + } finally { + await rm(a.cacheDir, { recursive: true, force: true }); + } + }); +}); + +describe("stack traces under --enable-source-maps", () => { + /** + * Boot slothlet in a child process against the fixture and return the stack of the error + * thrown by the `.ts` leaf. + * @param {object} typescript - The `typescript` config to boot with. + * @returns {string} The child's stdout (the error stack). + */ + function stackFromChild(typescript) { + const script = [ + `import slothlet from ${JSON.stringify(pathToFileURL(path.join(REPO_ROOT, "index.mjs")).href)};`, + `const api = await slothlet({ dir: ${JSON.stringify(path.dirname(leafPath))}, mode: "eager", silent: true, typescript: ${JSON.stringify(typescript)} });`, + `try { await api.boom("x"); } catch (error) { process.stdout.write(String(error.stack)); }`, + `await api.slothlet.shutdown();` + ].join("\n"); + // A script file rather than `-e`: strict mode forks its type-generation worker with the + // parent's execArgv, and `--input-type` is rejected for a file entry point. + const scriptPath = path.join(root, `child-${typescript.mode}-${Date.now()}.mjs`); + writeFileSync(scriptPath, script, "utf8"); + const result = spawnSync(process.execPath, ["--enable-source-maps", scriptPath], { + cwd: REPO_ROOT, + env: process.env, + encoding: "utf8", + timeout: 90000 + }); + expect(result.status, result.stderr).toBe(0); + return result.stdout; + } + + it("points the throwing frame at the .ts source when sourcemap is true (fast mode)", () => { + const stack = stackFromChild({ mode: "fast", sourcemap: true }); + expect(stack).toContain("boom-499:x"); + expect(stack).toContain(`${leafPath}:3`); + }, 60000); + + it("points the throwing frame at the .ts source when sourcemap is true (strict mode)", () => { + const stack = stackFromChild({ + mode: "strict", + sourcemap: true, + types: { output: path.join(root, "strict-stack.d.ts"), interfaceName: "StackAPI" } + }); + expect(stack).toContain("boom-499:x"); + expect(stack).toContain(`${leafPath}:3`); + }, 90000); + + it("points the throwing frame at the cache copy when sourcemap is off", () => { + const stack = stackFromChild({ mode: "fast" }); + expect(stack).toContain("boom-499:x"); + expect(stack).not.toContain(leafPath); + }, 60000); +}); diff --git a/tests/vitests/suites/typescript/typescript-options-strict.test.vitest.mjs b/tests/vitests/suites/typescript/typescript-options-strict.test.vitest.mjs new file mode 100644 index 000000000..e9dc027b2 --- /dev/null +++ b/tests/vitests/suites/typescript/typescript-options-strict.test.vitest.mjs @@ -0,0 +1,172 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/typescript/typescript-options-strict.test.vitest.mjs + * @Date: 2026-09-28T00:00:00-07:00 (1790578800) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:00:00 -07:00 (1790578800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Strict mode honours `typescript.strict`, `typescript.module` and + * `typescript.compilerOptions` (#499). + * + * @description + * Each option is driven through the real `slothlet({...})` config path and proven to reach the + * type check by a leaf whose diagnostics depend on it: + * + * - `strict: false` — an implicit-`any` parameter (TS7006) is rejected by default and accepted + * once strict checking is turned off. + * - `compilerOptions: { noUnusedLocals: true }` — an unused local (TS6133) passes by default and + * is rejected once the flag is set. + * - `module: "commonjs"` (and the tsconfig-style `compilerOptions: { module: "commonjs" }`) — + * `import.meta` (TS1343) passes under the default ESNext module and is rejected under CommonJS. + * + * Every boot forks strict mode's type-generation pass, so the file runs solo + * (SOLO_RUN_PATTERNS in run-all-vitest.mjs) with a generous timeout. + * + * @module tests/vitests/suites/typescript/typescript-options-strict.test.vitest + */ + +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from "vitest"; +import path from "node:path"; +import { mkdir, writeFile, rm } from "node:fs/promises"; +import slothlet from "../../../../index.mjs"; +import { makeTestTmpDir } from "../../setup/test-fixtures-tmp.mjs"; +import { withSuppressedSlothletErrorOutput } from "../../setup/vitest-helper.mjs"; + +vi.setConfig({ testTimeout: 90000, hookTimeout: 90000 }); + +let root; +let api; + +/** + * Write a one-leaf API directory under the test root. + * @param {string} name - Directory name. + * @param {string} leaf - Leaf name; the file `.ts` exports a function of the same name, so + * it flattens to `api.`. + * @param {string} source - TypeScript source of the leaf. + * @returns {Promise} The API directory. + */ +async function apiDir(name, leaf, source) { + const dir = path.join(root, name); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, `${leaf}.ts`), source, "utf8"); + return dir; +} + +/** + * Boot slothlet in strict TypeScript mode. + * @param {string} dir - API directory. + * @param {object} [options] - Extra `typescript` options. + * @returns {Promise} The api. + */ +async function boot(dir, options = {}) { + api = await slothlet({ + dir, + mode: "eager", + silent: true, + typescript: { + mode: "strict", + types: { output: path.join(root, `${path.basename(dir)}-${Date.now()}.d.ts`), interfaceName: "OptionsAPI" }, + ...options + } + }); + return api; +} + +/** + * Assert that booting rejects with an error whose message names the given error code and detail. + * An eager boot wraps a leaf's load failure in MODULE_IMPORT_FAILED, so the inner code is matched + * in the message rather than on `error.code`. + * @param {string} dir - API directory. + * @param {object} options - Extra `typescript` options. + * @param {string} code - Expected SlothletError code. + * @param {string} detail - Text the message must also contain (e.g. the TypeScript diagnostic). + * @returns {Promise} + */ +async function expectBootRejects(dir, options, code, detail) { + await withSuppressedSlothletErrorOutput(async () => { + const error = await boot(dir, options).then( + () => null, + (err) => err + ); + expect(error).not.toBeNull(); + expect(error.message).toContain(code); + expect(error.message).toContain(detail); + }); +} + +let implicitAnyDir; +let unusedLocalDir; +let importMetaDir; + +beforeAll(async () => { + root = await makeTestTmpDir("ts-options-strict"); + implicitAnyDir = await apiDir("implicit-any", "echo", "export function echo(value) {\n\treturn value;\n}\n"); + unusedLocalDir = await apiDir( + "unused-local", + "double", + "export function double(value: number): number {\n\tconst unused = 1;\n\treturn value * 2;\n}\n" + ); + importMetaDir = await apiDir("import-meta", "where", "export function where(): string {\n\treturn import.meta.url;\n}\n"); +}); + +afterEach(async () => { + if (api?.slothlet?.shutdown) await api.slothlet.shutdown().catch(() => {}); + api = null; +}); + +afterAll(async () => { + if (root) await rm(root, { recursive: true, force: true }); +}); + +describe("typescript.strict", () => { + it("rejects an implicit-any parameter by default", async () => { + await expectBootRejects(implicitAnyDir, {}, "TS_TYPE_CHECK_ERRORS", "implicitly has an 'any' type"); + }); + + it("accepts an implicit-any parameter with strict: false", async () => { + await boot(implicitAnyDir, { strict: false }); + expect(await api.echo(7)).toBe(7); + }); +}); + +describe("typescript.compilerOptions", () => { + it("accepts an unused local by default", async () => { + await boot(unusedLocalDir); + expect(await api.double(4)).toBe(8); + }); + + it("rejects an unused local with compilerOptions.noUnusedLocals", async () => { + await expectBootRejects(unusedLocalDir, { compilerOptions: { noUnusedLocals: true } }, "TS_TYPE_CHECK_ERRORS", "'unused' is declared"); + }); + + it('applies tsconfig-style string values (compilerOptions.module: "commonjs" rejects import.meta)', async () => { + await expectBootRejects(importMetaDir, { compilerOptions: { module: "commonjs" } }, "TS_TYPE_CHECK_ERRORS", "import.meta"); + }); + + it("rejects an unknown compiler option with INVALID_CONFIG", async () => { + await expectBootRejects( + unusedLocalDir, + { compilerOptions: { notARealCompilerOption: true } }, + "INVALID_CONFIG", + "notARealCompilerOption" + ); + }); +}); + +describe("typescript.module", () => { + it("accepts import.meta under the default module", async () => { + await boot(importMetaDir); + expect(await api.where()).toMatch(/^file:/); + }); + + it('rejects import.meta with module: "commonjs"', async () => { + await expectBootRejects(importMetaDir, { module: "commonjs" }, "TS_TYPE_CHECK_ERRORS", "import.meta"); + }); +}); diff --git a/tests/vitests/suites/typescript/typescript-options-validation.test.vitest.mjs b/tests/vitests/suites/typescript/typescript-options-validation.test.vitest.mjs new file mode 100644 index 000000000..f4a9c8de1 --- /dev/null +++ b/tests/vitests/suites/typescript/typescript-options-validation.test.vitest.mjs @@ -0,0 +1,110 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/typescript/typescript-options-validation.test.vitest.mjs + * @Date: 2026-09-28T00:00:00-07:00 (1790578800) + * @Author: Shinrai + * @Email: + * ----- + * @Last modified by: Shinrai (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:00:00 -07:00 (1790578800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Normalization and validation of the `typescript` options object (#499). + * + * @description + * `normalizeTypeScript()` must carry every documented option — `target`, `sourcemap`, and the + * strict-mode options `module`, `strict`, `compilerOptions` — through to the normalized config, + * and reject a value of the wrong type with INVALID_CONFIG (both directly and through the real + * `slothlet({...})` config path). + * + * @module tests/vitests/suites/typescript/typescript-options-validation.test.vitest + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { Config } from "@cldmv/slothlet/helpers/config"; +import { SlothletError, SlothletWarning } from "@cldmv/slothlet/errors"; +import slothlet from "../../../../index.mjs"; +import { withSuppressedSlothletErrorOutput } from "../../setup/vitest-helper.mjs"; + +/** + * Minimal mock slothlet satisfying Config's ComponentBase requirements. + * @returns {object} Mock slothlet instance. + */ +function makeMock() { + return { + config: {}, + debug: vi.fn(), + SlothletError, + SlothletWarning, + helpers: { resolver: { resolvePathFromCaller: (dir) => dir } } + }; +} + +let api; +afterEach(async () => { + if (api?.slothlet?.shutdown) await api.slothlet.shutdown().catch(() => {}); + api = null; +}); + +describe("normalizeTypeScript — option pass-through", () => { + it("carries module, strict and compilerOptions through the object form", () => { + const cfg = new Config(makeMock()); + const compilerOptions = { noUnusedLocals: true }; + const result = cfg.normalizeTypeScript({ mode: "strict", module: "esnext", strict: false, compilerOptions }); + expect(result).toMatchObject({ enabled: true, mode: "strict", module: "esnext", strict: false, compilerOptions }); + }); + + it("keeps sourcemap: true", () => { + const cfg = new Config(makeMock()); + expect(cfg.normalizeTypeScript({ mode: "fast", sourcemap: true })).toMatchObject({ sourcemap: true }); + }); + + it("is idempotent on an already-normalized config", () => { + const cfg = new Config(makeMock()); + const once = cfg.normalizeTypeScript({ mode: "strict", module: "esnext", strict: false, compilerOptions: { noUnusedLocals: true } }); + expect(cfg.normalizeTypeScript(once)).toEqual(once); + }); +}); + +describe("normalizeTypeScript — INVALID_CONFIG", () => { + const cases = [ + ["target", 2020], + ["module", 99], + ["strict", "no"], + ["sourcemap", "inline"], + ["compilerOptions", "noUnusedLocals"], + ["compilerOptions", ["noUnusedLocals"]], + ["compilerOptions", new Map()] + ]; + + it.each(cases)("rejects typescript.%s = %o", (option, value) => { + const cfg = new Config(makeMock()); + expect(() => cfg.normalizeTypeScript({ mode: "strict", [option]: value })).toThrow(expect.objectContaining({ code: "INVALID_CONFIG" })); + }); + + it.each([ + ["an array", ["x"], "array"], + ["a class instance", new Map(), "Map"], + ["a null-prototype object", Object.create(null), "object"], + ["a number", 1, "number"] + ])("reports %s as %o in the error", (_label, value, reported) => { + const cfg = new Config(makeMock()); + expect(() => cfg.normalizeTypeScript({ mode: "strict", strict: value })).toThrow( + expect.objectContaining({ + code: "INVALID_CONFIG", + context: expect.objectContaining({ option: "typescript.strict", value: reported }) + }) + ); + }); + + it("rejects an invalid option through slothlet()", async () => { + await withSuppressedSlothletErrorOutput(async () => { + await expect( + slothlet({ dir: "./api_tests/api_test_typescript", silent: true, typescript: { mode: "fast", strict: "yes" } }) + ).rejects.toMatchObject({ code: "INVALID_CONFIG" }); + }); + }); +}); diff --git a/types/src/lib/helpers/config.d.mts b/types/src/lib/helpers/config.d.mts index 9d8858b00..ef3f9ab26 100644 --- a/types/src/lib/helpers/config.d.mts +++ b/types/src/lib/helpers/config.d.mts @@ -319,6 +319,7 @@ export class Config extends ComponentBase { * // => { defaultPolicy: "deny", enabled: true, audit: "default", readGating: true, rules: [...] } */ normalizePermissions(permissions?: object | null): object | null; + #private; } import { ComponentBase } from "#factories/component-base"; //# sourceMappingURL=config.d.mts.map \ No newline at end of file diff --git a/types/src/lib/helpers/config.d.mts.map b/types/src/lib/helpers/config.d.mts.map index 028f79721..580cbde93 100644 --- a/types/src/lib/helpers/config.d.mts.map +++ b/types/src/lib/helpers/config.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"config.d.mts","sourceRoot":"","sources":["../../../../src/lib/helpers/config.mjs"],"names":[],"mappings":"AAwEA;;;;;;;;;;;;;;;;;GAiBG;AACH,2CAJW,OAAO,GAAC,MAAM,GAAC,MAAM,GACnB;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;IAAC,cAAc,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,OAAO,CAAA;CAAC,CAqB7F;AAED;;;;;GAKG;AACH;IACC,gCAAmC;IAEnC;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACH,qCAxBW,MAAM,GAAC,MAAM,GACX,MAAM,CAiDlB;IAED;;;;;OAKG;IACH,iCAJW,MAAM,GACJ,MAAM,CAsBlB;IAED;;;;;OAKG;IACH,2BAJW,MAAM,GACJ,MAAM,CAsBlB;IAED;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,qCAnBW,MAAM,GACJ,MAAM,CAyClB;IAED;;;;;OAKG;IACH,6BAJW,OAAO,GAAC,MAAM,GACZ,MAAM,CAoElB;IAED;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,oCATW,GAAC,0BACC,SAAS,GAAC,MAAM,CAoB5B;IAED;;;;;;;;;;;;;;OAcG;IACH,4BAJW,OAAO,GAAC,MAAM,GAAC,MAAM,GACnB;QAAC,OAAO,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;QAAC,cAAc,EAAE,OAAO,CAAC;QAAC,GAAG,EAAE,OAAO,CAAA;KAAC,CAK7F;IAED;;;;;;OAMG;IACH,gCALW,MAAM,GACJ,MAAM,CAuQlB;IAED;;;;;;;;;;;OAWG;IACH,6CARW,MAAM,EAAE,GAAC,SAAS,UAClB,OAAO,GACL,GAAG,CAAC,MAAM,CAAC,CA8BvB;IAED;;;;;OAKG;IACH,uCAJW,OAAO,GAAC,MAAM,GAAC,MAAM,GACnB,MAAM,GAAC,IAAI,CAsCvB;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;IACH,yBArBW,MAAM,GAAC,IAAI,GAAC,SAAS,GAEnB;QAAE,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,GAAC,IAAI,CA4BtC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACH,qCAbW,MAAM,GAAC,IAAI,GAAC,SAAS,GACnB,MAAM,GAAC,IAAI,CAqEvB;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6CG;IACH,mCApBW,SAAS,GAAC,IAAI,GAAC,KAAK,CAAC,MAAM,GAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,OAAO,CAAA;KAAC,CAAC,GAChH,KAAK,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,QAAQ,GAAC,SAAS,GAAC,UAAU,GAAC,SAAS,CAAC;QAAC,SAAS,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,OAAO,GAAC,OAAO,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAC,CAAC,CAiO9I;IAED;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACH,mCAvBW,MAAM,GAAC,IAAI,GAiBT,MAAM,GAAC,IAAI,CAgQvB;CACD;8BA92C6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"config.d.mts","sourceRoot":"","sources":["../../../../src/lib/helpers/config.mjs"],"names":[],"mappings":"AAwEA;;;;;;;;;;;;;;;;;GAiBG;AACH,2CAJW,OAAO,GAAC,MAAM,GAAC,MAAM,GACnB;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;IAAC,cAAc,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,OAAO,CAAA;CAAC,CAqB7F;AAED;;;;;GAKG;AACH;IACC,gCAAmC;IAEnC;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACH,qCAxBW,MAAM,GAAC,MAAM,GACX,MAAM,CAiDlB;IAED;;;;;OAKG;IACH,iCAJW,MAAM,GACJ,MAAM,CAsBlB;IAED;;;;;OAKG;IACH,2BAJW,MAAM,GACJ,MAAM,CAsBlB;IAED;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,qCAnBW,MAAM,GACJ,MAAM,CAyClB;IAED;;;;;OAKG;IACH,6BAJW,OAAO,GAAC,MAAM,GACZ,MAAM,CAoElB;IAED;;;;;;;;;;;;;;;;;;;;;;OAsBG;IACH,oCATW,GAAC,0BACC,SAAS,GAAC,MAAM,CAoB5B;IAED;;;;;;;;;;;;;;OAcG;IACH,4BAJW,OAAO,GAAC,MAAM,GAAC,MAAM,GACnB;QAAC,OAAO,EAAE,OAAO,CAAC;QAAC,OAAO,EAAE,CAAC,MAAM,GAAC,IAAI,CAAC,CAAC;QAAC,cAAc,EAAE,OAAO,CAAC;QAAC,GAAG,EAAE,OAAO,CAAA;KAAC,CAK7F;IAED;;;;;;OAMG;IACH,gCALW,MAAM,GACJ,MAAM,CAuQlB;IAED;;;;;;;;;;;OAWG;IACH,6CARW,MAAM,EAAE,GAAC,SAAS,UAClB,OAAO,GACL,GAAG,CAAC,MAAM,CAAC,CA8BvB;IAED;;;;;OAKG;IACH,uCAJW,OAAO,GAAC,MAAM,GAAC,MAAM,GACnB,MAAM,GAAC,IAAI,CAmDvB;IAqCD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6BG;IACH,yBArBW,MAAM,GAAC,IAAI,GAAC,SAAS,GAEnB;QAAE,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,GAAC,IAAI,CA4BtC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACH,qCAbW,MAAM,GAAC,IAAI,GAAC,SAAS,GACnB,MAAM,GAAC,IAAI,CAqEvB;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA6CG;IACH,mCApBW,SAAS,GAAC,IAAI,GAAC,KAAK,CAAC,MAAM,GAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,OAAO,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,CAAC,EAAE,OAAO,CAAA;KAAC,CAAC,GAChH,KAAK,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,QAAQ,GAAC,SAAS,GAAC,UAAU,GAAC,SAAS,CAAC;QAAC,SAAS,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,OAAO,GAAC,OAAO,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAC,CAAC,CAiO9I;IAED;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACH,mCAvBW,MAAM,GAAC,IAAI,GAiBT,MAAM,GAAC,IAAI,CAgQvB;;CACD;8BA95C6B,2BAA2B"} \ No newline at end of file diff --git a/types/src/lib/processors/loader.d.mts.map b/types/src/lib/processors/loader.d.mts.map index 7c73d7f1a..82614ac2d 100644 --- a/types/src/lib/processors/loader.d.mts.map +++ b/types/src/lib/processors/loader.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"loader.d.mts","sourceRoot":"","sources":["../../../../src/lib/processors/loader.mjs"],"names":[],"mappings":"AA4DA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,sDAjBW,MAAM,6BAEd;IAAqC,MAAM,GAAnC,MAAM,GAAC,SAAS;IACI,YAAY;CAExC,GAAU,OAAO,CAmBnB;AAwCD;;;;;GAKG;AACH;;;;;;;;;;GAUG;AACH,4CALa,MAAM,CAOlB;AAED;IACC,gCAAmC;IAEnC;;;;OAIG;IACH,sBAHW,MAAM,EAKhB;IAED;;;;;;;;OAQG;IACH,4BAPW,MAAM,eACN,MAAM,aACN,MAAM,cACN,MAAM,GAAC,IAAI,GACT,OAAO,CAAC,MAAM,CAAC,CAuM3B;IAyED;;;;;;;;;;;;;;OAcG;IACH,0BAbW,MAAM,YAEd;QAA0B,UAAU;QACX,YAAY;QACZ,QAAQ;QACD,UAAU;QACM,MAAM;QAE5B,iBAAiB;QAClB,OAAO;KAChC,GAAU,OAAO,CAAC,MAAM,CAAC,CAqJ3B;IA8PD;;;;;OAKG;IACH,8BAJW,MAAM,GACJ,MAAM,CA+ClB;;CACD;8BAh3B6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"loader.d.mts","sourceRoot":"","sources":["../../../../src/lib/processors/loader.mjs"],"names":[],"mappings":"AA4DA;;;;;;;;;;;;;;;;;;;GAmBG;AACH,sDAjBW,MAAM,6BAEd;IAAqC,MAAM,GAAnC,MAAM,GAAC,SAAS;IACI,YAAY;CAExC,GAAU,OAAO,CAmBnB;AAwCD;;;;;GAKG;AACH;;;;;;;;;;GAUG;AACH,4CALa,MAAM,CAOlB;AAED;IACC,gCAAmC;IAEnC;;;;OAIG;IACH,sBAHW,MAAM,EAKhB;IAED;;;;;;;;OAQG;IACH,4BAPW,MAAM,eACN,MAAM,aACN,MAAM,cACN,MAAM,GAAC,IAAI,GACT,OAAO,CAAC,MAAM,CAAC,CAwM3B;IAyED;;;;;;;;;;;;;;OAcG;IACH,0BAbW,MAAM,YAEd;QAA0B,UAAU;QACX,YAAY;QACZ,QAAQ;QACD,UAAU;QACM,MAAM;QAE5B,iBAAiB;QAClB,OAAO;KAChC,GAAU,OAAO,CAAC,MAAM,CAAC,CAqJ3B;IA8PD;;;;;OAKG;IACH,8BAJW,MAAM,GACJ,MAAM,CA+ClB;;CACD;8BAj3B6B,2BAA2B"} \ No newline at end of file diff --git a/types/src/lib/processors/typescript.d.mts b/types/src/lib/processors/typescript.d.mts index 598f414eb..5fe5f7700 100644 --- a/types/src/lib/processors/typescript.d.mts +++ b/types/src/lib/processors/typescript.d.mts @@ -4,7 +4,8 @@ * @param {object} [options={}] - esbuild transform options * @param {string} [options.target] - ECMAScript target version (default: "es2020") * @param {string} [options.format] - Module format (default: "esm") - * @param {boolean} [options.sourcemap] - Generate source maps (default: false) + * @param {boolean} [options.sourcemap] - Append an inline source map whose `sources` names the + * absolute path of `filePath`, so the cached output maps back to the `.ts` source (default: false) * @returns {Promise} Transformed JavaScript code * @throws {SlothletError} If transformation fails * @public @@ -174,7 +175,13 @@ export function writeTransformedToCache(originalPath: string, code: string, inst * @param {boolean} [options.strict] - Enable strict type checking (default: true) * @param {boolean} [options.skipTypeCheck] - Skip type checking and only transform (default: false) * @param {string} [options.typeDefinitionPath] - Path to .d.ts file for type checking + * @param {boolean} [options.sourcemap] - Append an inline source map whose `sources` names the + * absolute path of `filePath` (default: false) + * @param {object} [options.compilerOptions] - Extra compiler options in tsconfig.json form + * (`{ noUnusedLocals: true, module: "commonjs" }`), applied over the options above. Relative + * paths resolve against the current working directory. * @returns {Promise<{code: string, diagnostics: object[]}>} Transformed code and type diagnostics + * @throws {SlothletError} INVALID_CONFIG when `options.compilerOptions` holds an unknown option or an invalid value * @throws {SlothletError} If transformation fails * @public */ @@ -184,6 +191,8 @@ export function transformTypeScriptStrict(filePath: string, options?: { strict?: boolean | undefined; skipTypeCheck?: boolean | undefined; typeDefinitionPath?: string | undefined; + sourcemap?: boolean | undefined; + compilerOptions?: object | undefined; }): Promise<{ code: string; diagnostics: object[]; diff --git a/types/src/lib/processors/typescript.d.mts.map b/types/src/lib/processors/typescript.d.mts.map index e2bd8bf37..0b27ef3d5 100644 --- a/types/src/lib/processors/typescript.d.mts.map +++ b/types/src/lib/processors/typescript.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"typescript.d.mts","sourceRoot":"","sources":["../../../../src/lib/processors/typescript.mjs"],"names":[],"mappings":"AAyEA;;;;;;;;;;GAUG;AACH,8CATW,MAAM,YAEd;IAAyB,MAAM;IACN,MAAM;IACL,SAAS;CACnC,GAAU,OAAO,CAAC,MAAM,CAAC,CAiB3B;AAED;;;;;GAKG;AACH,oCAJW,MAAM,GACJ,MAAM,CAQlB;AA6HD;;;;;;;;;;;;;GAaG;AACH,2CAHa,OAAO,CAAC,IAAI,CAAC,CAyCzB;AAED;;;;;;;;GAQG;AACH,6CAHa,OAAO,CAAC,OAAO,CAAC,CAiB5B;AA+CD;;;;;;;;;;;GAWG;AACH,kDAJW,MAAM,GACJ;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,CA4B3C;AA+CD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,6CAJW,MAAM,GACJ,UAAU,CA2EtB;AAmDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,gDATW,MAAM,cACN,MAAM,YACN,CAAC,cAAc,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,MAAM,GAInE,MAAM,CA4ClB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,sDARW,MAAM,QACN,MAAM,cACN,MAAM,cACN,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAEnC,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAC,CAAC,CAwBpD;AA4GD;;;;;;;;;;;;GAYG;AACH,oDAXW,MAAM,YAEd;IAAyB,MAAM;IACN,MAAM;IACL,MAAM;IACN,aAAa;IACd,kBAAkB;CAC3C,GAAU,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,EAAE,CAAA;CAAC,CAAC,CA6F1D;AAED;;;;;;GAMG;AACH,+CALW,MAAM,EAAE,MACR,MAAM,GACJ,MAAM,EAAE,CAapB"} \ No newline at end of file +{"version":3,"file":"typescript.d.mts","sourceRoot":"","sources":["../../../../src/lib/processors/typescript.mjs"],"names":[],"mappings":"AAyEA;;;;;;;;;;;GAWG;AACH,8CAVW,MAAM,YAEd;IAAyB,MAAM;IACN,MAAM;IACL,SAAS;CAEnC,GAAU,OAAO,CAAC,MAAM,CAAC,CAqB3B;AAED;;;;;GAKG;AACH,oCAJW,MAAM,GACJ,MAAM,CAQlB;AA6HD;;;;;;;;;;;;;GAaG;AACH,2CAHa,OAAO,CAAC,IAAI,CAAC,CAyCzB;AAED;;;;;;;;GAQG;AACH,6CAHa,OAAO,CAAC,OAAO,CAAC,CAiB5B;AA+CD;;;;;;;;;;;GAWG;AACH,kDAJW,MAAM,GACJ;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,CA4B3C;AA+CD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,6CAJW,MAAM,GACJ,UAAU,CA2EtB;AAmDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,gDATW,MAAM,cACN,MAAM,YACN,CAAC,cAAc,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,KAAK,MAAM,GAInE,MAAM,CA4ClB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AACH,sDARW,MAAM,QACN,MAAM,cACN,MAAM,cACN,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,GAEnC,OAAO,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAC,CAAC,CAwBpD;AA4GD;;;;;;;;;;;;;;;;;;GAkBG;AACH,oDAjBW,MAAM,YAEd;IAAyB,MAAM;IACN,MAAM;IACL,MAAM;IACN,aAAa;IACd,kBAAkB;IACjB,SAAS;IAEV,eAAe;CAGxC,GAAU,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,EAAE,CAAA;CAAC,CAAC,CAgG1D;AAiDD;;;;;;GAMG;AACH,+CALW,MAAM,EAAE,MACR,MAAM,GACJ,MAAM,EAAE,CAapB"} \ No newline at end of file From 5620bfd21ccc80d60b83b98709a9137d710f0c9a Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 28 Sep 2026 14:08:30 -0700 Subject: [PATCH 18/49] feat(permissions): add slothlet.lockCaller.caller to pin the calling module onto a callback lockCaller pins the leaf that calls it, so a service that accepts a callback on behalf of whoever called it (a scheduler's every(), a registry) could only pin itself and the callback ran as the service. self.slothlet.lockCaller.caller(fn) pins the current leaf's caller instead: the identity metadata.caller() reports, resolved through a shared Metadata#callerWrapper() so the two cannot diverge. Acting as your caller is a privilege, so slothlet.lockCaller.caller is its own api path with a built-in deny for every module; the host grants it, e.g. { caller: "scheduler.**", target: "slothlet.lockCaller.caller", effect: "allow" }. The exact-path built-in allow on slothlet.lockCaller does not cover it. The check runs on the route and again inside the function, since the function is handed out as a plain value. When the leaf was called from outside any module, the callback is pinned to no module caller and runs as the host (metadata.caller() is null inside it). Both context managers gain an asHost flag on runInContext: async clears both wrappers and marks the execution store trusted-root; live clears the shared fields and tracks the held call as suspended so a concurrently resumed module call is resolved from its stack rather than trusting the null field. Otherwise it matches lockCaller: captured at call time, errors propagate unchanged, this/args forwarded, _slothletOriginal set, and instanceID and contextManager resolved live across reload(). lockCaller itself is unchanged. Fixes #477 --- .../client/app.mjs | 174 ++++++++ .../roles/registry.mjs | 51 +++ .../scheduler/service.mjs | 126 ++++++ .../target/probe.mjs | 55 +++ docs/HOOKS.md | 27 +- docs/PERMISSIONS.md | 12 + src/lib/builders/api_builder.mjs | 76 ++++ src/lib/handlers/context-async.mjs | 17 +- src/lib/handlers/context-live.mjs | 25 +- src/lib/handlers/metadata.mjs | 20 +- src/lib/handlers/permission-manager.mjs | 8 + src/slothlet.mjs | 2 +- tests/vitests/setup/vitest-helper.mjs | 1 + .../lock-caller-caller.test.vitest.mjs | 399 ++++++++++++++++++ types/src/lib/builders/api_builder.d.mts.map | 2 +- types/src/lib/handlers/context-async.d.mts | 6 +- .../src/lib/handlers/context-async.d.mts.map | 2 +- types/src/lib/handlers/context-live.d.mts | 6 +- types/src/lib/handlers/context-live.d.mts.map | 2 +- types/src/lib/handlers/metadata.d.mts | 12 + types/src/lib/handlers/metadata.d.mts.map | 2 +- .../lib/handlers/permission-manager.d.mts.map | 2 +- 22 files changed, 1010 insertions(+), 17 deletions(-) create mode 100644 api_tests/api_test_lock_caller_caller/client/app.mjs create mode 100644 api_tests/api_test_lock_caller_caller/roles/registry.mjs create mode 100644 api_tests/api_test_lock_caller_caller/scheduler/service.mjs create mode 100644 api_tests/api_test_lock_caller_caller/target/probe.mjs create mode 100644 tests/vitests/suites/permissions/lock-caller-caller.test.vitest.mjs diff --git a/api_tests/api_test_lock_caller_caller/client/app.mjs b/api_tests/api_test_lock_caller_caller/client/app.mjs new file mode 100644 index 000000000..12473315f --- /dev/null +++ b/api_tests/api_test_lock_caller_caller/client/app.mjs @@ -0,0 +1,174 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_lock_caller_caller/client/app.mjs + * @Date: 2026-09-28 00:00:00 -07:00 (1790578800) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:00:00 -07:00 (1790578800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Client module for the `lockCaller.caller()` tests (#477). Schedules jobs on the + * scheduler service; each job calls a probe target, so the target sees whichever identity the job + * runs as. + * + * @module api_tests/api_test_lock_caller_caller/client/app + */ + +import { self } from "@cldmv/slothlet/runtime"; + +/** + * Schedule a job that reports who the target sees calling it, pinned to this client. + * @returns {number} Stored job count. + * @public + * @example + * api.client.app.scheduleWhoami(); + */ +export function scheduleWhoami() { + return self.scheduler.service.every(() => self.target.probe.whoami()); +} + +/** + * Same job, but the scheduler pins itself (`lockCaller`), for comparison. + * @returns {number} Stored job count. + * @public + * @example + * api.client.app.scheduleWhoamiAsService(); + */ +export function scheduleWhoamiAsService() { + return self.scheduler.service.everyAsSelf(() => self.target.probe.whoami()); +} + +/** + * Schedule a job that calls the client-only guarded route. + * @returns {number} Stored job count. + * @public + * @example + * api.client.app.scheduleGuarded(); + */ +export function scheduleGuarded() { + return self.scheduler.service.every(() => self.target.probe.guarded()); +} + +/** + * Schedule the guarded job with the scheduler pinning itself, for comparison. + * @returns {number} Stored job count. + * @public + * @example + * api.client.app.scheduleGuardedAsService(); + */ +export function scheduleGuardedAsService() { + return self.scheduler.service.everyAsSelf(() => self.target.probe.guarded()); +} + +/** + * Schedule an async job that awaits a timer before reporting who the target sees. + * @returns {number} Stored job count. + * @public + * @example + * api.client.app.scheduleAsyncWhoami(); + */ +export function scheduleAsyncWhoami() { + return self.scheduler.service.every(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + return self.target.probe.whoami(); + }); +} + +/** + * Schedule a job that throws a TypeError. + * @returns {number} Stored job count. + * @public + * @example + * api.client.app.scheduleThrow(); + */ +export function scheduleThrow() { + return self.scheduler.service.every(() => { + throw new TypeError("job-boom"); + }); +} + +/** + * Schedule a job that returns its own `this` and arguments. + * @returns {number} Stored job count. + * @public + * @example + * api.client.app.scheduleThisAndArgs(); + */ +export function scheduleThisAndArgs() { + return self.scheduler.service.every(function thisAndArgs(...args) { + return { self: this, args }; + }); +} + +/** + * Schedule a job that calls the principal-gated route for the project it is fired with. + * @returns {number} Stored job count. + * @public + * @example + * api.client.app.scheduleFiles(); + */ +export function scheduleFiles() { + return self.scheduler.service.every((projectId) => self.target.probe.files(projectId)); +} + +/** + * Call the principal-gated route directly as this client. + * @param {string} projectId - Project id. + * @returns {{projectId: string}} The project marker. + * @public + * @example + * api.client.app.readFiles("p1"); + */ +export function readFiles(projectId) { + return self.target.probe.files(projectId); +} + +/** + * The principal-gated job with the scheduler pinning itself, for comparison. + * @returns {number} Stored job count. + * @public + * @example + * api.client.app.scheduleFilesAsService(); + */ +export function scheduleFilesAsService() { + return self.scheduler.service.everyAsSelf((projectId) => self.target.probe.files(projectId)); +} + +/** + * Ask the scheduler for a pinned wrapper and hand back both it and the original callback. + * @returns {{wrapper: Function, original: Function}} The wrapper and the callback it pins. + * @public + * @example + * api.client.app.pinThroughScheduler(); + */ +export function pinThroughScheduler() { + const original = () => "original"; + return { wrapper: self.scheduler.service.pin(original), original }; +} + +/** + * Call `lockCaller.caller` directly from this (ungranted) client. + * @returns {Function} The pinned wrapper (only when granted). + * @public + * @example + * api.client.app.pinDirectly(); + */ +export function pinDirectly() { + return self.slothlet.lockCaller.caller(() => 0); +} + +/** + * Fire the scheduler from inside this client, so the client is the ambient identity. + * @returns {Promise>} The jobs' results. + * @public + * @example + * await api.client.app.fireFromClient(); + */ +export function fireFromClient() { + return self.scheduler.service.fire(); +} diff --git a/api_tests/api_test_lock_caller_caller/roles/registry.mjs b/api_tests/api_test_lock_caller_caller/roles/registry.mjs new file mode 100644 index 000000000..8a075a906 --- /dev/null +++ b/api_tests/api_test_lock_caller_caller/roles/registry.mjs @@ -0,0 +1,51 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_lock_caller_caller/roles/registry.mjs + * @Date: 2026-09-28 00:00:00 -07:00 (1790578800) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:00:00 -07:00 (1790578800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Owner of the "roles" principal (#459) for the `lockCaller.caller()` tests (#477). + * Grants are per user, per project. + * + * @module api_tests/api_test_lock_caller_caller/roles/registry + */ + +import { self } from "@cldmv/slothlet/runtime"; + +const grants = new Map(); + +/** + * Register the "roles" principal, keyed by `ctx.user`. + * @returns {void} + * @public + * @example + * api.roles.registry.setup(); + */ +export function setup() { + self.slothlet.permissions.principal.register("roles", { + key: (ctx) => ctx.user, + resolve: async (user) => ({ projects: { ...(grants.get(user) ?? {}) } }) + }); +} + +/** + * Grant a user permissions on a project. + * @param {string} user - User id. + * @param {string} projectId - Project id. + * @param {string[]} perms - Permissions. + * @returns {void} + * @public + * @example + * api.roles.registry.grant("u1", "p1", ["read"]); + */ +export function grant(user, projectId, perms) { + grants.set(user, { ...(grants.get(user) ?? {}), [projectId]: perms }); +} diff --git a/api_tests/api_test_lock_caller_caller/scheduler/service.mjs b/api_tests/api_test_lock_caller_caller/scheduler/service.mjs new file mode 100644 index 000000000..8faa6f7d1 --- /dev/null +++ b/api_tests/api_test_lock_caller_caller/scheduler/service.mjs @@ -0,0 +1,126 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_lock_caller_caller/scheduler/service.mjs + * @Date: 2026-09-28 00:00:00 -07:00 (1790578800) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:00:00 -07:00 (1790578800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Scheduler-style service for the `lockCaller.caller()` tests (#477). + * + * @description + * Accepts callbacks on behalf of whoever calls it and runs them later from {@link fire}. `every` + * pins the CALLER of `every` onto the job (`self.slothlet.lockCaller.caller`); `everyAsSelf` pins the + * service itself (`self.slothlet.lockCaller`) for comparison. + * + * @module api_tests/api_test_lock_caller_caller/scheduler/service + */ + +import { self } from "@cldmv/slothlet/runtime"; + +let jobs = []; + +/** + * Store a job pinned to the identity of whoever called this leaf. + * @param {Function} job - Callback to run on {@link fire}. + * @returns {number} The number of stored jobs. + * @public + * @example + * api.scheduler.service.every(() => 1); + */ +export function every(job) { + jobs.push(self.slothlet.lockCaller.caller(job)); + return jobs.length; +} + +/** + * Store a job pinned to this service (plain `lockCaller`). + * @param {Function} job - Callback to run on {@link fire}. + * @returns {number} The number of stored jobs. + * @public + * @example + * api.scheduler.service.everyAsSelf(() => 1); + */ +export function everyAsSelf(job) { + jobs.push(self.slothlet.lockCaller(job)); + return jobs.length; +} + +/** + * Return the `lockCaller.caller()` wrapper for `job` without storing it. + * @param {Function} job - Callback to pin. + * @returns {Function} The pinned wrapper. + * @public + * @example + * api.scheduler.service.pin(() => 1); + */ +export function pin(job) { + return self.slothlet.lockCaller.caller(job); +} + +/** + * Run every stored job, sequentially, forwarding `args`. + * @param {...*} args - Arguments forwarded to each job. + * @returns {Promise>} Each job's (awaited) result. + * @public + * @example + * await api.scheduler.service.fire(); + */ +export async function fire(...args) { + const results = []; + for (const job of jobs) results.push(await job(...args)); + return results; +} + +/** + * Run every stored job concurrently, forwarding `args`. + * @param {...*} args - Arguments forwarded to each job. + * @returns {Promise>} Each job's result. + * @public + * @example + * await api.scheduler.service.fireAll(); + */ +export function fireAll(...args) { + return Promise.all(jobs.map((job) => job(...args))); +} + +/** + * Run every stored job synchronously with an explicit `this`, returning the raw results. + * @param {*} thisArg - `this` binding for each job. + * @param {...*} args - Arguments forwarded to each job. + * @returns {Array<*>} Each job's result. + * @public + * @example + * api.scheduler.service.fireWith({ tag: 1 }); + */ +export function fireWith(thisArg, ...args) { + return jobs.map((job) => job.apply(thisArg, args)); +} + +/** + * The raw stored job wrappers (for metadata inspection). + * @returns {Function[]} The stored wrappers. + * @public + * @example + * api.scheduler.service.list(); + */ +export function list() { + return [...jobs]; +} + +/** + * Drop every stored job. + * @returns {void} + * @public + * @example + * api.scheduler.service.clear(); + */ +export function clear() { + jobs = []; +} diff --git a/api_tests/api_test_lock_caller_caller/target/probe.mjs b/api_tests/api_test_lock_caller_caller/target/probe.mjs new file mode 100644 index 000000000..e9789449e --- /dev/null +++ b/api_tests/api_test_lock_caller_caller/target/probe.mjs @@ -0,0 +1,55 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_lock_caller_caller/target/probe.mjs + * @Date: 2026-09-28 00:00:00 -07:00 (1790578800) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:00:00 -07:00 (1790578800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview Probe targets for the `lockCaller.caller()` tests (#477): report who called them, and + * permission-gated routes keyed to the caller. + * + * @module api_tests/api_test_lock_caller_caller/target/probe + */ + +import { self } from "@cldmv/slothlet/runtime"; + +/** + * Report the api path of the module that called this leaf. + * @returns {string|null} The caller's api path, or null when called from the host. + * @public + * @example + * api.target.probe.whoami(); // null (host) + */ +export function whoami() { + return self.slothlet.metadata.caller()?.apiPath ?? null; +} + +/** + * Permission-gated route: rules allow it for the client and deny it for the scheduler. + * @returns {string} A constant marker. + * @public + * @example + * api.target.probe.guarded(); + */ +export function guarded() { + return "guarded-ok"; +} + +/** + * Principal-gated route (#459): allowed for the client when its roles grant "read" on `projectId`. + * @param {string} projectId - Project id. + * @returns {{projectId: string}} The project marker. + * @public + * @example + * api.target.probe.files("p1"); + */ +export function files(projectId) { + return { projectId }; +} diff --git a/docs/HOOKS.md b/docs/HOOKS.md index 83f2b4e51..c3683f5db 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -746,6 +746,31 @@ server.addHook( - Called with no active context (no module wrapper to capture), `lockCaller` is a no-op passthrough — it is meaningful only when called from inside a module. - **Runtime mode matters for async callbacks.** In **async** runtime mode the pinned identity propagates through `AsyncLocalStorage`, so `self.*` calls after an `await` still resolve to the registering module. In **live** runtime mode the caller is pinned only for the **synchronous** portion of the callback — the live context manager restores the previous wrapper as soon as the callback returns its promise. Once the synchronous `runInContext()` stack has unwound there may be **no slothlet caller at all**, so an `async` callback that `await`s before calling `self.*` resumes with whatever context is then active — typically none, and an identity probe sees `unknown`. Live mode keeps no per-async-task context; use **async** runtime mode for `async` hooks (like the example above) that must keep locked identity past their first `await`. `bind` is **not** an escape hatch here — it has the same live-mode limitation (see below); async runtime mode is the only fix. +### `self.slothlet.lockCaller.caller(fn)` — pin the leaf's caller + +`lockCaller` pins the leaf that calls it. A service that accepts a callback on behalf of whoever called it — a scheduler's `every(interval, fn)`, a registry — can therefore only pin **itself**, and the callback runs as the service rather than the module that handed it over. `lockCaller.caller` pins the current leaf's **caller** instead: exactly the identity `self.slothlet.metadata.caller()` reports at that moment. + +```javascript +// scheduler/service.mjs +import { self } from "@cldmv/slothlet/runtime"; + +export function every(ms, job) { + // Runs each job as the module that called every(), not as the scheduler. + setInterval(self.slothlet.lockCaller.caller(job), ms); +} +``` + +```javascript +// client/app.mjs — the job runs as client.app.*, so rules keyed to the client apply inside it. +self.scheduler.service.every(60_000, () => self.reports.refresh()); +``` + +- **It is a privilege.** Acting as your caller is not something a module may do by default: `slothlet.lockCaller.caller` is its own api path, denied to every module by a built-in rule. The host grants it to the services that run callbacks on their callers' behalf — `{ caller: "scheduler.**", target: "slothlet.lockCaller.caller", effect: "allow" }`. See [Permissions](PERMISSIONS.md#other-slothlet-routes-are-gated-too). The check runs when the wrapper is created. +- **No module caller → runs as the host.** When the leaf was itself called from outside any module (the host, a transport edge), the callback is pinned to "no module caller": it runs as the host, and `metadata.caller()` returns `null` inside it. This is a real pin, not a passthrough of `fn`, and it does not throw. Because a host-pinned callback carries the host's exemption from rules, grant `lockCaller.caller` only to modules trusted to act for any caller, the host included. +- **Otherwise identical to `lockCaller`**: the identity is captured once, at call time, and cannot be changed afterwards; `this` and arguments are forwarded; errors thrown by `fn` propagate unchanged; the wrapper exposes `_slothletOriginal`; a callback held across `reload()` runs against the current instance. +- **Principals compose.** Calls made inside the callback are enforced for the pinned identity, so rules keyed to the caller — including [`requires` principals](PERMISSIONS.md#principals) — resolve exactly as they would for a direct call from that caller. +- **Runtime mode** carries the same caveat as `lockCaller`: in **async** runtime mode the pinned identity propagates through `AsyncLocalStorage` across every `await`. In **live** runtime mode there is one identity slot per instance, so while an `async` callback is parked at an `await` alongside other suspended calls (for example the service that fired it and is awaiting it), its resumed calls are attributed from the call stack and can resolve to an awaiting module frame instead of the pin. That fails closed — never to the host — but it is not the pin; use async runtime mode for `async` callbacks that must keep the pinned caller past an `await`. + ### `self.slothlet.bind(fn)` — freeze the whole async context `bind` is a convenience re-export of Node's `AsyncResource.bind`. Unlike `lockCaller`, it freezes the **entire** async context captured at registration time — every `AsyncLocalStorage`, including slothlet's caller and request context: @@ -758,7 +783,7 @@ Reach for `lockCaller` when you want the caller pinned but request-scoped contex Both utilities share the same live-mode limitation: `AsyncResource.bind` only meaningfully captures slothlet's caller/context in **async** runtime mode. In live mode the slothlet store is kept off the `AsyncLocalStorage`, so `bind` degrades to binding whatever other async context exists and does **not** preserve slothlet caller identity past an `await`. `bind` is therefore not a workaround for the live-mode `lockCaller` caveat above — if an `async` callback must keep slothlet identity across awaits, run the instance in **async** runtime mode. -> `slothlet.lockCaller` and `slothlet.bind` are permission-gated routes like every other `slothlet.*` member — see [Permissions](PERMISSIONS.md#other-slothlet-routes-are-gated-too). +> `slothlet.lockCaller`, `slothlet.lockCaller.caller`, and `slothlet.bind` are permission-gated routes like every other `slothlet.*` member — see [Permissions](PERMISSIONS.md#other-slothlet-routes-are-gated-too). `lockCaller.caller` is denied to modules by default. --- diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index e91cf2924..e578e342a 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -727,6 +727,18 @@ The entire `slothlet` namespace is wrapped by an internal route proxy, so every A more specific user rule can still deny them for a particular module if needed. The whole point of `lockCaller` is downstream of this: the callback it returns runs with the caller identity set to the **registering** module, so permission rules keyed to that module match instead of failing against whatever module's async context happened to be ambient when the callback fired. +`slothlet.lockCaller.caller` is the exception: it is **denied by default**. It pins the current leaf's _caller_ onto a callback, so the callback acts as another module (or, when the leaf was called from the host, as the host) — a privilege, not a strengthening. The built-in allow on `slothlet.lockCaller` is an exact-path rule and does not cover it. The host grants it to the service modules that run callbacks on their callers' behalf; this holds under both `defaultPolicy: "allow"` and `"deny"`: + +```javascript +// Built-in rule registered for every instance: +{ caller: "**", target: "slothlet.lockCaller.caller", effect: "deny" } + +// Host grant — let the scheduler run jobs as the modules that scheduled them: +{ caller: "scheduler.**", target: "slothlet.lockCaller.caller", effect: "allow" } +``` + +A module without the grant gets `PERMISSION_DENIED`. See [`lockCaller.caller`](HOOKS.md#selfslothletlockcallercallerfn--pin-the-leafs-caller). + `slothlet.metadata.caller` and `slothlet.metadata.self` are **allowed by default** for the same reason. They reveal identity only — which module is calling this one, and which module this is — and grant no data or control access; they are what a module needs to [authorize or scope by its caller](./METADATA.md#selfslothletmetadatacaller) under a `defaultPolicy: "deny"` configuration: ```javascript diff --git a/src/lib/builders/api_builder.mjs b/src/lib/builders/api_builder.mjs index e5b857ede..46f8ade76 100644 --- a/src/lib/builders/api_builder.mjs +++ b/src/lib/builders/api_builder.mjs @@ -3517,6 +3517,82 @@ export class ApiBuilder extends ComponentBase { }; } + /** + * Freeze the current leaf's CALLER identity onto a callback (#477). + * @param {Function} fn - The callback whose caller identity should be pinned. + * @returns {Function} A wrapper that invokes `fn` as the current leaf's caller. + * @throws {SlothletError} PERMISSION_DENIED when the calling module has not been granted + * `slothlet.lockCaller.caller`; INVALID_ARGUMENT when `fn` is not a function. + * @public + * + * @description + * `lockCaller` pins the leaf that calls it, so a service leaf accepting a callback on + * behalf of whoever called it (a scheduler's `every(interval, fn)`, a registry) can only + * pin itself — the callback would run as the service. `lockCaller.caller` pins the + * identity `self.slothlet.metadata.caller()` reports at the moment of the call (the same + * resolution, shared through the metadata handler), so the callback runs as the module + * that called the service. + * + * Acting as your caller is a privilege: the api path `slothlet.lockCaller.caller` is + * denied to every module by a built-in rule, and the host grants it, e.g. + * `{ caller: "scheduler.**", target: "slothlet.lockCaller.caller", effect: "allow" }`. + * The check runs when the wrapper is created, not when it is invoked. + * + * When the leaf itself was called from outside any module (the host, a transport edge), + * the callback is pinned to **no module caller**: it runs as the host, and + * `metadata.caller()` returns null inside it. This is a real pin, not a passthrough. + * + * Otherwise identical to `lockCaller`: captured once at call time and immutable after; + * `this` and arguments are forwarded; errors from `fn` propagate unchanged; the returned + * wrapper exposes `_slothletOriginal`; instanceID/contextManager are resolved live so a + * callback held across `reload()` targets the current instance. Runtime-mode behaviour is + * the same as `lockCaller`'s — in **async** mode the identity propagates through + * `AsyncLocalStorage` across every `await`; in **live** mode there is one identity slot per + * instance, so an async callback resumed while other calls are also suspended is attributed + * from the call stack (failing closed, never to the host) — see docs/HOOKS.md. + * + * @example + * // scheduler/service.mjs — runs each job as the module that scheduled it. + * export function every(ms, job) { + * setInterval(self.slothlet.lockCaller.caller(job), ms); + * } + */ + const lockCallerCaller = function slothlet_lockCaller_caller(fn) { + // Enforced here as well as on the route: the function is handed out as a plain value + // (`caller` is a meta-property of the route proxy), so a reference passed on to another + // module must still be refused for that module. + enforceInternalPermission("slothlet.lockCaller.caller"); + if (typeof fn !== "function") { + throw new slothlet.SlothletError("INVALID_ARGUMENT", { + argument: "fn", + expected: "function", + received: typeof fn, + validationError: true + }); + } + // The identity metadata.caller() reports right now — null when the current leaf was + // called from outside any module, which pins "no module caller" (run as the host). + const capturedCaller = slothlet.handlers.metadata.callerWrapper(); + const locked = function slothlet_lockedCallerCaller(...args) { + // rawErrors: surface fn's own errors unchanged. instanceID/contextManager resolved live + // so a callback held across a reload() targets the current instance. + return capturedCaller + ? slothlet.contextManager.runInContext(slothlet.instanceID, fn, this, args, capturedCaller, true) + : slothlet.contextManager.runInContext(slothlet.instanceID, fn, this, args, null, true, true); + }; + // Parity with lockCaller / the EventEmitter patch metadata. + locked._slothletOriginal = fn; + return locked; + }; + // `lockCaller.caller` is its own api path (`slothlet.lockCaller.caller`), so the permission + // system gates it independently of `lockCaller`, whose call behaviour is unchanged. + Object.defineProperty(namespace.lockCaller, "caller", { + value: lockCallerCaller, + enumerable: true, + writable: false, + configurable: false + }); + return createInternalRouteProxy(namespace, "slothlet"); } diff --git a/src/lib/handlers/context-async.mjs b/src/lib/handlers/context-async.mjs index 9e879ff18..68fdd8f67 100644 --- a/src/lib/handlers/context-async.mjs +++ b/src/lib/handlers/context-async.mjs @@ -22,6 +22,7 @@ import { AsyncLocalStorage } from "@cldmv/slothlet/helpers/platform"; import { SlothletError } from "@cldmv/slothlet/errors"; import { runtime_isClassInstance, runtime_wrapClassInstance } from "@cldmv/slothlet/helpers/class-instance-wrapper"; import { setApiContextChecker } from "@cldmv/slothlet/helpers/eventemitter-context"; +import { TRUSTED_ROOT } from "#handlers/trusted-root"; /** * AsyncLocalStorage-based context manager for async runtime @@ -84,10 +85,14 @@ export class AsyncContextManager { * `fn` propagate unchanged instead of wrapping it as `CONTEXT_EXECUTION_FAILED`. Used * for framework callbacks (`lockCaller`, pinned hooks) where the caller expects the * original error type/code/status. + * @param {boolean} [asHost=false] - When `true`, run `fn` with **no module caller**: both + * `currentWrapper` and `callerWrapper` are cleared for the execution, so `fn` runs as the host + * (`metadata.caller()` returns null inside it). `currentWrapper` is ignored. Used by + * `lockCaller.caller()` when the pinned caller is the host. * @returns {*} Result of function execution * @public */ - runInContext(instanceID, fn, thisArg, args, currentWrapper, rawErrors = false) { + runInContext(instanceID, fn, thisArg, args, currentWrapper, rawErrors = false, asHost = false) { // Check if we're already in an active ALS context const activeStore = this.als.getStore(); let baseStore; @@ -111,7 +116,15 @@ export class AsyncContextManager { // Create a new store with currentWrapper for this execution const executionStore = { ...baseStore }; - if (currentWrapper) { + if (asHost) { + // Pinned to "no module caller": the flow runs as the host, not as whichever module is ambient. + executionStore.callerWrapper = null; + executionStore.currentWrapper = null; + // Running as the host means enforcement must see a host-initiated flow. The marker is + // non-enumerable (never spread onto a module's execution store), so apply it explicitly — the + // same marker the instance's base store carries for a genuinely host-initiated call. + Object.defineProperty(executionStore, TRUSTED_ROOT, { value: true, configurable: true }); + } else if (currentWrapper) { executionStore.callerWrapper = baseStore.currentWrapper; executionStore.currentWrapper = currentWrapper; } diff --git a/src/lib/handlers/context-live.mjs b/src/lib/handlers/context-live.mjs index 8fa0e6165..a9bbf558f 100644 --- a/src/lib/handlers/context-live.mjs +++ b/src/lib/handlers/context-live.mjs @@ -207,8 +207,9 @@ export class LiveContextManager { * @private */ #resolveSuspendedFromStack(suspended) { + // Host-pinned calls (`lockCaller.caller()` with no module caller) are tracked with no filePath: + // they have no module frame to match. When only those are suspended, no candidate can match. const candidates = [...suspended].filter((entry) => entry.filePath); - /* v8 ignore next — entries always carry a filePath; guards a partial mock. */ if (!candidates.length) return null; // Raise the frame budget for this capture only: the caller's frame sits below slothlet's own @@ -367,10 +368,14 @@ export class LiveContextManager { * `fn` propagate unchanged instead of wrapping it as `CONTEXT_EXECUTION_FAILED`. Used * for framework callbacks (`lockCaller`, pinned hooks) where the caller expects the * original error type/code/status. + * @param {boolean} [asHost=false] - When `true`, run `fn` with **no module caller**: both + * `currentWrapper` and `callerWrapper` are cleared for the execution, so `fn` runs as the host + * (`metadata.caller()` returns null inside it). `currentWrapper` is ignored. Used by + * `lockCaller.caller()` when the pinned caller is the host. * @returns {*} Result of function execution * @public */ - runInContext(instanceID, fn, thisArg, args, currentWrapper, rawErrors = false) { + runInContext(instanceID, fn, thisArg, args, currentWrapper, rawErrors = false, asHost = false) { // CHILD INSTANCE APPROACH: Check if current is this instance OR a child of this instance const currentID = this.currentInstanceID; const isAlreadyInContext = this.#flowBelongsToInstance(currentID, instanceID); @@ -392,6 +397,11 @@ export class LiveContextManager { const previousCallerWrapper = store.callerWrapper; this.currentInstanceID = targetInstanceID; + if (asHost) { + // Pinned to "no module caller": the call runs as the host, not as whichever module is ambient. + store.callerWrapper = null; + store.currentWrapper = null; + } // currentWrapper is optional; false branch is covered directly in context-live-branches tests // but v8 hit-counter overflows to -255 in the parallel matrix, appearing uncovered. /* v8 ignore next */ @@ -429,7 +439,9 @@ export class LiveContextManager { // thenable and hand back a plain promise in its place. Those reads carry their own // caller snapshot taken when the proxy was created, so they stay attributed anyway. if (result instanceof Promise) { - /* v8 ignore next — filePath/apiPath are set on every live wrapper; ?? guards a partial mock. */ + // A host-pinned call (`asHost`) has no wrapper, so it is tracked with an empty api path and + // no filePath (see #resolveSuspendedFromStack). + /* v8 ignore next — a live wrapper always carries filePath/apiPath; the ?? on a present wrapper guards a partial mock. */ const apiPath = currentWrapper?.____slothletInternal?.apiPath ?? ""; const entry = { currentWrapper, @@ -444,8 +456,13 @@ export class LiveContextManager { // identity to disambiguate later, so there is nothing to record. Every promise-returning call // reaching here carries the wrapper being invoked, so the skip arm guards a caller-less entry // this path is not handed. + // + // A call pinned to the host (`asHost`) is the exception: it deliberately holds the shared + // field at null for its whole async lifetime. Were it untracked, a single suspended module + // call resuming meanwhile would trust that null field and be treated as the host — failing + // open. Tracking it keeps the count honest, so that module is resolved from its stack instead. /* v8 ignore next */ - if (currentWrapper) this.#suspendedFor(store).add(entry); + if (currentWrapper || asHost) this.#suspendedFor(store).add(entry); const settle = () => { this.#suspendedFor(store).delete(entry); restore(); diff --git a/src/lib/handlers/metadata.mjs b/src/lib/handlers/metadata.mjs index a30e8278a..210f71f17 100644 --- a/src/lib/handlers/metadata.mjs +++ b/src/lib/handlers/metadata.mjs @@ -855,8 +855,24 @@ export class Metadata extends ComponentBase { * @public */ caller() { + const callerWrapper = this.callerWrapper(); + if (!callerWrapper) return null; + return this.getMetadata(callerWrapper); + } + + /** + * Resolve the wrapper of the API function that called the current function. + * + * The single resolution behind {@link Metadata#caller}: reads `callerWrapper` from the active + * context-manager store. `self.slothlet.lockCaller.caller()` pins exactly this identity, so the + * two can never disagree about who "the caller" is. + * + * @returns {object|null} The calling function's wrapper, or null when there is no module caller + * in context (the function was invoked from the host or outside the API). + * @public + */ + callerWrapper() { const ctx = this.slothlet.contextManager?.tryGetContext(); - if (!ctx || !ctx.callerWrapper) return null; - return this.getMetadata(ctx.callerWrapper); + return ctx?.callerWrapper ?? null; } } diff --git a/src/lib/handlers/permission-manager.mjs b/src/lib/handlers/permission-manager.mjs index 2033d4d55..c75d292f4 100644 --- a/src/lib/handlers/permission-manager.mjs +++ b/src/lib/handlers/permission-manager.mjs @@ -386,6 +386,14 @@ export class PermissionManager extends ComponentBase { this.addRule({ caller: "**", target: "slothlet.lockCaller", effect: "allow" }, "__builtin__"); this.addRule({ caller: "**", target: "slothlet.bind", effect: "allow" }, "__builtin__"); + // Built-in deny rule (#477): `slothlet.lockCaller.caller` pins the current leaf's CALLER onto + // a callback, so the callback acts as another module — a privilege, unlike `lockCaller`, which + // only pins the module itself. Host-only by default; the host grants it to the service modules + // that run callbacks on their callers' behalf (e.g. `{ caller: "scheduler.**", target: + // "slothlet.lockCaller.caller", effect: "allow" }`). The exact-path allow on `slothlet.lockCaller` + // above does not match this deeper path. + this.addRule({ caller: "**", target: "slothlet.lockCaller.caller", effect: "deny" }, "__builtin__"); + // Built-in allow rules (#468): `slothlet.metadata.caller` / `slothlet.metadata.self` reveal // identity only — who is calling this module, and which module this is — which is what a module // needs to enforce its own policy (e.g. scoping records by caller). They grant no data or diff --git a/src/slothlet.mjs b/src/slothlet.mjs index 83e53f0b6..97c824b60 100644 --- a/src/slothlet.mjs +++ b/src/slothlet.mjs @@ -1717,7 +1717,7 @@ export default slothlet; * @property {Function} slothlet.versioning.unregister - Unregister a specific version from a logical API path. Removes the versioned namespace (`vX.path`) and updates or tears down the dispatcher. If the logical path/version is not registered, this resolves without removing anything. %%sig: (logicalPath: string, versionTag: string): Promise.%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api', versionDispatcher: "version" });|await api.slothlet.api.add('auth', './api/v1', {}, { version: 'v1' });|await api.slothlet.api.add('auth', './api/v2', {}, { version: 'v2' });|const removed = await api.slothlet.versioning.unregister('auth', 'v2'); // true%% * @property {Function} slothlet.versioning.getVersionMetadata - Retrieve the VersionManager-only metadata stored at registration time via `versionConfig.metadata`. Returns `undefined` if the path or version is not registered, or if versioning has not yet been used. %%sig: (logicalPath: string, versionTag: string): object|undefined%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api', versionDispatcher: "version" });|await api.slothlet.api.add('auth', './api/v1', {}, { version: 'v1', metadata: { stable: true } });|const meta = api.slothlet.versioning.getVersionMetadata('auth', 'v1'); // { stable: true, version: 'v1', logicalPath: 'auth' }%% * @property {Function} slothlet.versioning.setVersionMetadata - Patch (merge) the VersionManager-only metadata for a registered version at runtime. The injected `version` and `logicalPath` keys always win over supplied patch fields. Throws `VERSION_NOT_FOUND` if the path or version is not registered. %%sig: (logicalPath: string, versionTag: string, patch: object): void%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api', versionDispatcher: "version" });|await api.slothlet.api.add('auth', './api/v1', {}, { version: 'v1' });|api.slothlet.versioning.setVersionMetadata('auth', 'v1', { stable: true });%% - * @property {Function} slothlet.lockCaller - Freeze the registering module's caller identity onto a callback. The returned wrapper overrides only the caller identity when invoked; request-scoped context stays live. In **async** runtime mode the pinned identity propagates through `AsyncLocalStorage` for the callback's full async lifetime. In **live** runtime mode identity is pinned only for the synchronous portion — once `fn` returns its promise the live context is restored, so an async callback that `await`s before calling `self.*` resumes with no slothlet caller at all (an identity check sees `unknown`); use async runtime mode for async hooks (e.g. Fastify `addHook`) that must keep identity past the first `await`. %%sig: (fn: function): function%% %%example: // Inside a module's init()|server.addHook("onRequest", self.slothlet.lockCaller(handler));%% + * @property {Function} slothlet.lockCaller - Freeze the registering module's caller identity onto a callback. The returned wrapper overrides only the caller identity when invoked; request-scoped context stays live. In **async** runtime mode the pinned identity propagates through `AsyncLocalStorage` for the callback's full async lifetime. In **live** runtime mode identity is pinned only for the synchronous portion — once `fn` returns its promise the live context is restored, so an async callback that `await`s before calling `self.*` resumes with no slothlet caller at all (an identity check sees `unknown`); use async runtime mode for async hooks (e.g. Fastify `addHook`) that must keep identity past the first `await`. **`slothlet.lockCaller.caller(fn)`** (a property of this function, same signature) pins the current leaf's **caller** instead — the identity `slothlet.metadata.caller()` reports — so a service that runs callbacks on behalf of whoever called it (a scheduler, a registry) runs each one as that caller rather than as itself. It is denied to modules by a built-in rule on `slothlet.lockCaller.caller`; the host grants it (e.g. `{ caller: "scheduler.**", target: "slothlet.lockCaller.caller", effect: "allow" }`). When the leaf was called from outside any module the callback is pinned to no module caller and runs as the host (`metadata.caller()` returns null inside it). Otherwise it behaves like `lockCaller`, including the runtime-mode caveat. %%sig: (fn: function): function%% %%example: // Inside a module's init()|server.addHook("onRequest", self.slothlet.lockCaller(handler));%% %%example: // Inside a scheduler leaf (host-granted `slothlet.lockCaller.caller`)|export function every(ms, job) {| setInterval(self.slothlet.lockCaller.caller(job), ms);|}%% * @property {Function} slothlet.bind - Freeze the entire async context onto a callback at registration time (convenience re-export of `AsyncResource.bind`). Unlike `lockCaller`, which overrides only caller identity and leaves request-scoped context live, `bind` freezes every `AsyncLocalStorage`, including slothlet's caller and context. Meaningful in async runtime mode; degrades to non-slothlet ALS context in live mode. %%sig: (fn: function): function%% %%example: // Inside a module's init()|server.addHook("onRequest", self.slothlet.bind(handler));%% * @property {object} [slothlet.reference] - The `reference` object from config, merged onto the root API and accessible here. * @property {Function} slothlet.reload - Reload the entire instance (re-scans the directory and recreates all module references). Accepts `{ keepInstanceID: boolean }`. %%sig: ([options]: Object): Promise.%% %%example: // ESM usage via slothlet API|import slothlet from "@cldmv/slothlet";|const api = await slothlet({ base: './api' });|await api.slothlet.reload(); // full 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' });| await api.slothlet.reload(); // full reload|}%% %%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.reload(); // full reload|})();%% %%example: // CJS usage via slothlet API (inside async function)|const slothlet = require("@cldmv/slothlet");|const api = await slothlet({ base: './api' });|await api.slothlet.reload(); // full reload%% diff --git a/tests/vitests/setup/vitest-helper.mjs b/tests/vitests/setup/vitest-helper.mjs index bfc2d9bbc..a57da9566 100644 --- a/tests/vitests/setup/vitest-helper.mjs +++ b/tests/vitests/setup/vitest-helper.mjs @@ -67,6 +67,7 @@ export const TEST_DIRS = { API_TEST_EVENTS: path.resolve(__dirname, `../../../${API_TEST_BASE}/api_test_events`), API_TEST_CROSS_INSTANCE: path.resolve(__dirname, `../../../${API_TEST_BASE}/api_test_cross_instance`), API_TEST_LOCK_CALLER: path.resolve(__dirname, `../../../${API_TEST_BASE}/api_test_lock_caller`), + API_TEST_LOCK_CALLER_CALLER: path.resolve(__dirname, `../../../${API_TEST_BASE}/api_test_lock_caller_caller`), API_TEST_BROWSER: path.resolve(__dirname, `../../../${API_TEST_BASE}/api_test_browser`), API_TEST_MULTI_ROOT_FN: path.resolve(__dirname, `../../../${API_TEST_BASE}/api_test_multi_root_fn`), API_TEST_LIFECYCLE_HOOKS: path.resolve(__dirname, `../../../${API_TEST_BASE}/api_test_lifecycle_hooks`), diff --git a/tests/vitests/suites/permissions/lock-caller-caller.test.vitest.mjs b/tests/vitests/suites/permissions/lock-caller-caller.test.vitest.mjs new file mode 100644 index 000000000..9b224fb30 --- /dev/null +++ b/tests/vitests/suites/permissions/lock-caller-caller.test.vitest.mjs @@ -0,0 +1,399 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /tests/vitests/suites/permissions/lock-caller-caller.test.vitest.mjs + * @Date: 2026-09-28 00:00:00 -07:00 (1790578800) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 00:00:00 -07:00 (1790578800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview #477 — `self.slothlet.lockCaller.caller(fn)` pins the current leaf's CALLER onto a + * callback, so a service that runs callbacks on behalf of whoever called it (a scheduler, a registry) + * runs each one as that caller rather than as itself. Acting as your caller is a privilege: the path + * `slothlet.lockCaller.caller` is denied to every module by a built-in rule and granted by the host. + * + * Fixture: `scheduler.service` stores jobs (`every` pins the caller, `everyAsSelf` pins itself via + * plain `lockCaller`); `client.app` schedules jobs that call `target.probe.*`, which reports / gates on + * the identity it is called with; `roles.registry` owns a #459 principal. + * + * @module tests/vitests/suites/permissions/lock-caller-caller + */ + +import { describe, it, expect, afterEach } from "vitest"; +import slothlet from "@cldmv/slothlet"; +import { getMatrixConfigs, TEST_DIRS, withSuppressedSlothletErrorOutput } from "../../setup/vitest-helper.mjs"; + +const BASE = TEST_DIRS.API_TEST_LOCK_CALLER_CALLER; + +/** The host grant that lets the scheduler pin its callers. */ +const GRANT = { caller: "scheduler.**", target: "slothlet.lockCaller.caller", effect: "allow" }; + +/** `target.probe.guarded` is for the client only. */ +const GUARD_RULES = [ + { caller: "scheduler.**", target: "target.probe.guarded", effect: "deny" }, + { caller: "client.**", target: "target.probe.guarded", effect: "allow" } +]; + +/** Under `defaultPolicy: "deny"`, the calls the fixture makes besides the one under test. */ +const DENY_POLICY_BASE_RULES = [ + { caller: "client.**", target: "scheduler.**", effect: "allow" }, + { caller: "client.**", target: "target.probe.whoami", effect: "allow" }, + { caller: "scheduler.**", target: "target.probe.whoami", effect: "allow" } +]; + +/** The job each client scheduling leaf pins — the identity a pinned job runs as. */ +const CLIENT_WHOAMI = "client.app.scheduleWhoami"; + +/** + * Assert an invocation fails with `PERMISSION_DENIED`, whether it throws synchronously (eager) or + * rejects (lazy first call). + * @param {() => unknown} invoke - Zero-arg thunk. + * @returns {Promise} + */ +const expectDenied = (invoke) => + withSuppressedSlothletErrorOutput(async () => { + let error; + try { + await invoke(); + } catch (err) { + error = err; + } + expect(error).toBeDefined(); + expect(error.code).toBe("PERMISSION_DENIED"); + }); + +describe.each(getMatrixConfigs())("Permissions > lockCaller.caller (#477) > $name", ({ config }) => { + let api; + + afterEach(async () => { + if (api) { + await api.scheduler.service.clear(); + await api.shutdown(); + } + api = null; + }); + + /** + * Build the fixture api. + * @param {object[]} rules - Permission rules. + * @param {"allow"|"deny"} [defaultPolicy="allow"] - Default policy. + * @returns {Promise} The api. + */ + const build = async (rules, defaultPolicy = "allow") => { + api = await slothlet({ ...config, base: BASE, permissions: { defaultPolicy, rules } }); + await api.scheduler.service.clear(); + return api; + }; + + // ── Pinning the caller ────────────────────────────────────────────────────── + + it("pins the leaf's caller, not the leaf: the target sees the client that scheduled the job", async () => { + await build([GRANT]); + await api.client.app.scheduleWhoami(); + expect(await api.scheduler.service.fire()).toEqual([CLIENT_WHOAMI]); + }); + + it("plain lockCaller in the same leaf pins the service itself (the problem lockCaller.caller solves)", async () => { + await build([GRANT]); + await api.client.app.scheduleWhoamiAsService(); + expect(await api.scheduler.service.fire()).toEqual(["scheduler.service.everyAsSelf"]); + }); + + it("a permission rule keyed to the caller applies inside the pinned job", async () => { + await build([GRANT, ...GUARD_RULES]); + await api.client.app.scheduleGuarded(); + expect(await api.scheduler.service.fire()).toEqual(["guarded-ok"]); + + // Pinned to the scheduler instead, the same job is denied. + await api.scheduler.service.clear(); + await api.client.app.scheduleGuardedAsService(); + await expectDenied(() => api.scheduler.service.fire()); + }); + + it("the pinned identity holds regardless of which module fires the job", async () => { + await build([GRANT]); + await api.client.app.scheduleWhoami(); + // Fired from inside the client (ambient client.app.fireFromClient → scheduler.service.fire). + expect(await api.client.app.fireFromClient()).toEqual([CLIENT_WHOAMI]); + }); + + // ── No module caller ──────────────────────────────────────────────────────── + + it("a leaf called from the host pins no module caller: the job runs as the host", async () => { + await build([GRANT]); + const job = () => ({ seen: api.target.probe.whoami(), caller: api.slothlet.metadata.caller() }); + await api.scheduler.service.every(job); + + // Fired from inside a module — a passthrough would run the job as scheduler.service.fire. + const [result] = await api.client.app.fireFromClient(); + expect(await result.seen).toBeNull(); + expect(result.caller).toBeNull(); + }); + + it("the host pin is a real wrapper, not a passthrough of fn", async () => { + await build([GRANT]); + const job = () => 1; + await api.scheduler.service.every(job); + const [wrapper] = await api.scheduler.service.list(); + expect(wrapper).not.toBe(job); + expect(wrapper._slothletOriginal).toBe(job); + expect(wrapper()).toBe(1); + }); + + it("host code calling lockCaller.caller directly gets a host pin and does not throw", async () => { + await build([]); + const job = () => api.target.probe.whoami(); + const pinned = api.slothlet.lockCaller.caller(job); + expect(pinned).not.toBe(job); + expect(await pinned()).toBeNull(); + }); + + it("an async host-pinned job keeps running as the host across an await", async () => { + await build([GRANT]); + await api.scheduler.service.every(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + return api.target.probe.whoami(); + }); + const [job] = await api.scheduler.service.list(); + expect(await job()).toBeNull(); + }); + + // Async runtime only — the same caveat lockCaller carries. The live runtime keeps one identity slot + // per instance, so while the job is parked at its await, the modules awaiting it (client → scheduler) + // are suspended too; the job's resumed call is then attributed from the stack, which names the + // awaiting scheduler frame. That fails closed (a module identity, not the host), but it is not the pin. + it.skipIf(config.runtime === "live")( + "async runtime: a host-pinned job fired from inside a module runs as the host across an await", + async () => { + await build([GRANT]); + await api.scheduler.service.every(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + return api.target.probe.whoami(); + }); + expect(await api.client.app.fireFromClient()).toEqual([null]); + } + ); + + it("concurrent async host-pinned jobs all run as the host", async () => { + await build([GRANT]); + const job = async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + return api.target.probe.whoami(); + }; + await api.scheduler.service.every(job); + await api.scheduler.service.every(job); + const [first, second] = await api.scheduler.service.list(); + // Invoked straight from the host so only the two host-pinned jobs are in flight. + expect(await Promise.all([first(), second()])).toEqual([null, null]); + }); + + // ── Permission gate ───────────────────────────────────────────────────────── + + it("is denied to a module without a grant under defaultPolicy allow", async () => { + await build([]); + await expectDenied(() => api.client.app.scheduleWhoami()); + await expectDenied(() => api.client.app.pinDirectly()); + }); + + it("is denied to a module without a grant under defaultPolicy deny", async () => { + await build(DENY_POLICY_BASE_RULES, "deny"); + await expectDenied(() => api.client.app.scheduleWhoami()); + await expectDenied(() => api.client.app.pinDirectly()); + }); + + it("is allowed with a host grant under defaultPolicy deny", async () => { + await build([...DENY_POLICY_BASE_RULES, GRANT], "deny"); + await api.client.app.scheduleWhoami(); + expect(await api.scheduler.service.fire()).toEqual([CLIENT_WHOAMI]); + // The grant is scoped: the client itself still may not pin its caller. + await expectDenied(() => api.client.app.pinDirectly()); + }); + + it("the built-in exact allow on slothlet.lockCaller does not cover slothlet.lockCaller.caller", async () => { + await build([GRANT], "deny"); + const global = api.slothlet.permissions.global; + expect(global.checkAccess("client.app", "slothlet.lockCaller")).toBe(true); + expect(global.checkAccess("client.app", "slothlet.lockCaller.caller")).toBe(false); + expect(global.checkAccess("scheduler.service", "slothlet.lockCaller.caller")).toBe(true); + }); + + it("the built-in deny applies under defaultPolicy allow too", async () => { + await build([]); + const global = api.slothlet.permissions.global; + expect(global.checkAccess("anyModule", "slothlet.lockCaller")).toBe(true); + expect(global.checkAccess("anyModule", "slothlet.lockCaller.caller")).toBe(false); + }); + + // ── Principals (#459) ─────────────────────────────────────────────────────── + + /** + * Rules for the principals tests: the client may read a project its roles grant "read" on. + * @type {object[]} + */ + const PRINCIPAL_RULES = [ + GRANT, + { caller: "client.**", target: "scheduler.**", effect: "allow" }, + { caller: "roles.**", target: "slothlet.permissions.principal.**", effect: "allow" }, + { + caller: "client.**", + target: "target.probe.files", + effect: "allow", + requires: ["roles"], + condition: (ctx, { args, principals }) => principals.roles.projects[args[0]]?.includes("read") === true + } + ]; + + /** + * Build the principals fixture: the roles principal registered and u1 granted read on p1. + * @returns {Promise<(ctx: object, fn: () => unknown) => unknown>} A per-request context runner. + */ + const buildPrincipals = async () => { + await build(PRINCIPAL_RULES, "deny"); + await api.roles.registry.setup(); + await api.roles.registry.grant("u1", "p1", ["read"]); + return (ctx, fn) => api.slothlet.context.run(ctx, fn); + }; + + it("principals: the pinned caller's principal-gated rule applies inside the job", async () => { + const as = await buildPrincipals(); + // Resolve u1's roles once through a direct client call, so the job takes the synchronous path. + expect(await as({ user: "u1" }, () => api.client.app.readFiles("p1"))).toEqual({ projectId: "p1" }); + + await api.client.app.scheduleFiles(); + expect(await as({ user: "u1" }, () => api.scheduler.service.fire("p1"))).toEqual([{ projectId: "p1" }]); + // Same identity, a project its roles grant nothing on: the resolved facts deny it. + await expectDenied(() => as({ user: "u1" }, () => api.scheduler.service.fire("p2"))); + + // Pinned to the scheduler, the client rule never matches. + await api.scheduler.service.clear(); + await api.client.app.scheduleFilesAsService(); + await expectDenied(() => as({ user: "u1" }, () => api.scheduler.service.fire("p1"))); + }); + + // Async runtime only: a stale principal promotes the call past an await, and in the live runtime the + // deferred enforcement is attributed from the stack while the scheduler is suspended awaiting the job — + // the same caveat lockCaller carries (see the async host-pin test above). + it.skipIf(config.runtime === "live")("async runtime: a principal is resolved for the pinned caller from inside the job", async () => { + const as = await buildPrincipals(); + await api.client.app.scheduleFiles(); + // No prior resolve: the job's call is promoted to resolve u1's roles, then gated on them. + expect(await as({ user: "u1" }, () => api.scheduler.service.fire("p1"))).toEqual([{ projectId: "p1" }]); + await expectDenied(() => as({ user: "u2" }, () => api.scheduler.service.fire("p1"))); + }); + + // ── lockCaller parity ─────────────────────────────────────────────────────── + + it("errors thrown by the job propagate unchanged", async () => { + await build([GRANT]); + await api.client.app.scheduleThrow(); + const [wrapper] = await api.scheduler.service.list(); + let error; + try { + wrapper(); + } catch (err) { + error = err; + } + expect(error).toBeInstanceOf(TypeError); + expect(error.message).toBe("job-boom"); + + const hostPinned = api.slothlet.lockCaller.caller(() => { + throw new RangeError("host-boom"); + }); + expect(() => hostPinned()).toThrow(RangeError); + }); + + it("forwards `this` and arguments into the job", async () => { + await build([GRANT]); + await api.client.app.scheduleThisAndArgs(); + const thisArg = { tag: "the-this" }; + const [result] = await api.scheduler.service.fireWith(thisArg, 1, 2); + expect(result.self).toBe(thisArg); + expect(result.args).toEqual([1, 2]); + }); + + it("exposes the original function via `_slothletOriginal`", async () => { + await build([GRANT]); + const { wrapper, original } = await api.client.app.pinThroughScheduler(); + expect(wrapper._slothletOriginal).toBe(original); + expect(wrapper()).toBe("original"); + }); + + it("the identity is captured at call time and cannot be changed afterwards", async () => { + await build([GRANT]); + await api.client.app.scheduleWhoami(); + const [wrapper] = await api.scheduler.service.list(); + // Invoked from the host, from another module, and again — always the client. + expect(await wrapper()).toBe(CLIENT_WHOAMI); + expect(await api.client.app.fireFromClient()).toEqual([CLIENT_WHOAMI]); + expect(await wrapper()).toBe(CLIENT_WHOAMI); + }); + + it("rejects a non-function argument", async () => { + await build([]); + let error; + try { + api.slothlet.lockCaller.caller(123); + } catch (err) { + error = err; + } + expect(error?.code).toBe("INVALID_ARGUMENT"); + }); + + it("a pinned job still targets the live instance after a full reload", async () => { + // Identity marker: only the client is denied the guarded route, so a denial naming the client proves + // the job still runs as the client (the host and the scheduler would both be allowed). + await build([GRANT, { caller: "client.**", target: "target.probe.guarded", effect: "deny" }]); + await api.client.app.scheduleGuarded(); + const [wrapper] = await api.scheduler.service.list(); + /** + * Invoke the pinned job and return the error it raised. + * @returns {Promise} The error. + */ + const invoke = () => + withSuppressedSlothletErrorOutput(async () => { + try { + await wrapper(); + } catch (err) { + return err; + } + return undefined; + }); + const before = await invoke(); + expect(before?.code).toBe("PERMISSION_DENIED"); + expect(before?.message).toContain("client.app.scheduleGuarded"); + + await api.slothlet.reload(); + const after = await invoke(); + expect(after?.code).toBe("PERMISSION_DENIED"); + expect(after?.message).toContain("client.app.scheduleGuarded"); + }); + + // Same runtime-mode behaviour as lockCaller (see the lock-caller runtime suite): the identity is held + // across the job's await in both runtimes. + it("an async job keeps the pinned caller across an await", async () => { + await build([GRANT]); + await api.client.app.scheduleAsyncWhoami(); + expect(await api.scheduler.service.fire()).toEqual(["client.app.scheduleAsyncWhoami"]); + }); + + it("plain lockCaller is unchanged: a function, host passthrough, and reachable .caller", async () => { + await build([]); + expect(typeof api.slothlet.lockCaller).toBe("function"); + expect(typeof api.slothlet.lockCaller.caller).toBe("function"); + const fn = () => 42; + expect(api.slothlet.lockCaller(fn)).toBe(fn); + expect(api.slothlet.lockCaller(fn)()).toBe(42); + }); + + it("lockCaller.caller is reachable through self for a granted module", async () => { + await build([{ caller: "client.**", target: "slothlet.lockCaller.caller", effect: "allow" }]); + const pinned = await api.client.app.pinDirectly(); + expect(typeof pinned).toBe("function"); + expect(pinned()).toBe(0); + }); +}); diff --git a/types/src/lib/builders/api_builder.d.mts.map b/types/src/lib/builders/api_builder.d.mts.map index 302e76339..3f1178f0e 100644 --- a/types/src/lib/builders/api_builder.d.mts.map +++ b/types/src/lib/builders/api_builder.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"api_builder.d.mts","sourceRoot":"","sources":["../../../../src/lib/builders/api_builder.mjs"],"names":[],"mappings":"AAmMA;;;;;;;;;;;;;GAaG;AACH;IACC,gCAAuC;IAEvC;;;;;;;OAOG;IACH,sBANW,MAAM,EAQhB;IAED;;;;;OAKG;IACH,8BAJW,MAAM,GACJ,OAAO,CAAC,MAAM,CAAC,CA6G3B;IAED;;;;;;;;;;;OAWG;IACH,gCA6jGC;IAED;;;;OAIG;IACH,+BA4BC;IAED;;;;OAIG;IACH,0BAgCC;IAED;;;;OAIG;IACH,4BAsTC;IAED;;;;;OAKG;IACH,8BAmEC;IAED;;;;;OAKG;IACH,uBAgCC;CACD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;8BAv3H6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"api_builder.d.mts","sourceRoot":"","sources":["../../../../src/lib/builders/api_builder.mjs"],"names":[],"mappings":"AAmMA;;;;;;;;;;;;;GAaG;AACH;IACC,gCAAuC;IAEvC;;;;;;;OAOG;IACH,sBANW,MAAM,EAQhB;IAED;;;;;OAKG;IACH,8BAJW,MAAM,GACJ,OAAO,CAAC,MAAM,CAAC,CA6G3B;IAED;;;;;;;;;;;OAWG;IACH,gCAyoGC;IAED;;;;OAIG;IACH,+BA4BC;IAED;;;;OAIG;IACH,0BAgCC;IAED;;;;OAIG;IACH,4BAsTC;IAED;;;;;OAKG;IACH,8BAmEC;IAED;;;;;OAKG;IACH,uBAgCC;CACD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;8BAn8H6B,2BAA2B"} \ No newline at end of file diff --git a/types/src/lib/handlers/context-async.d.mts b/types/src/lib/handlers/context-async.d.mts index 6d800a2c8..0b8dcec69 100644 --- a/types/src/lib/handlers/context-async.d.mts +++ b/types/src/lib/handlers/context-async.d.mts @@ -31,10 +31,14 @@ export class AsyncContextManager { * `fn` propagate unchanged instead of wrapping it as `CONTEXT_EXECUTION_FAILED`. Used * for framework callbacks (`lockCaller`, pinned hooks) where the caller expects the * original error type/code/status. + * @param {boolean} [asHost=false] - When `true`, run `fn` with **no module caller**: both + * `currentWrapper` and `callerWrapper` are cleared for the execution, so `fn` runs as the host + * (`metadata.caller()` returns null inside it). `currentWrapper` is ignored. Used by + * `lockCaller.caller()` when the pinned caller is the host. * @returns {*} Result of function execution * @public */ - public runInContext(instanceID: string, fn: Function, thisArg: any, args: any[], currentWrapper?: Object, rawErrors?: boolean): any; + public runInContext(instanceID: string, fn: Function, thisArg: any, args: any[], currentWrapper?: Object, rawErrors?: boolean, asHost?: boolean): any; /** * Get current active context * @returns {Object} Current context store diff --git a/types/src/lib/handlers/context-async.d.mts.map b/types/src/lib/handlers/context-async.d.mts.map index a3dffb814..80f3f4537 100644 --- a/types/src/lib/handlers/context-async.d.mts.map +++ b/types/src/lib/handlers/context-async.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"context-async.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/context-async.mjs"],"names":[],"mappings":"AAyBA;;;;GAIG;AACH;IAKE,SAA6D;IAC7D,yBAA0B;IAG3B;;;;OAIG;IACH,kDAKC;IAED;;;;;;OAMG;IACH,8BALW,MAAM,WACN,MAAM,GACJ,MAAM,CAkBlB;IAED;;;;;;;;;;;;;OAaG;IACH,gCAZW,MAAM,yBAEN,GAAC,gCAED,MAAM,cACN,OAAO,GAIL,GAAC,CA4Fb;IAqBD;;;;;OAKG;IACH,qBAJa,MAAM,CAUlB;IAED;;;;;;;;;;;;OAYG;IACH,kCAVW,MAAM,GAOJ,MAAM,GAAC,SAAS,CAe5B;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,sCAVW,MAAM,GAMJ;QAAC,cAAc,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAC,GAAC,SAAS,CAQrE;IAED;;;;OAIG;IACH,2BAHW,MAAM,QAoBhB;IAED;;;;OAIG;IACH,yBAHa,MAAM,CAclB;;CACD;AAED;;;GAGG;AACH,sDAA6D"} \ No newline at end of file +{"version":3,"file":"context-async.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/context-async.mjs"],"names":[],"mappings":"AA0BA;;;;GAIG;AACH;IAKE,SAA6D;IAC7D,yBAA0B;IAG3B;;;;OAIG;IACH,kDAKC;IAED;;;;;;OAMG;IACH,8BALW,MAAM,WACN,MAAM,GACJ,MAAM,CAkBlB;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,gCAhBW,MAAM,yBAEN,GAAC,gCAED,MAAM,cACN,OAAO,WAIP,OAAO,GAIL,GAAC,CAoGb;IAqBD;;;;;OAKG;IACH,qBAJa,MAAM,CAUlB;IAED;;;;;;;;;;;;OAYG;IACH,kCAVW,MAAM,GAOJ,MAAM,GAAC,SAAS,CAe5B;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,sCAVW,MAAM,GAMJ;QAAC,cAAc,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAC,GAAC,SAAS,CAQrE;IAED;;;;OAIG;IACH,2BAHW,MAAM,QAoBhB;IAED;;;;OAIG;IACH,yBAHa,MAAM,CAclB;;CACD;AAED;;;GAGG;AACH,sDAA6D"} \ No newline at end of file diff --git a/types/src/lib/handlers/context-live.d.mts b/types/src/lib/handlers/context-live.d.mts index a71c47842..e28db87cc 100644 --- a/types/src/lib/handlers/context-live.d.mts +++ b/types/src/lib/handlers/context-live.d.mts @@ -79,10 +79,14 @@ export class LiveContextManager { * `fn` propagate unchanged instead of wrapping it as `CONTEXT_EXECUTION_FAILED`. Used * for framework callbacks (`lockCaller`, pinned hooks) where the caller expects the * original error type/code/status. + * @param {boolean} [asHost=false] - When `true`, run `fn` with **no module caller**: both + * `currentWrapper` and `callerWrapper` are cleared for the execution, so `fn` runs as the host + * (`metadata.caller()` returns null inside it). `currentWrapper` is ignored. Used by + * `lockCaller.caller()` when the pinned caller is the host. * @returns {*} Result of function execution * @public */ - public runInContext(instanceID: string, fn: Function, thisArg: any, args: any[], currentWrapper?: Object, rawErrors?: boolean): any; + public runInContext(instanceID: string, fn: Function, thisArg: any, args: any[], currentWrapper?: Object, rawErrors?: boolean, asHost?: boolean): any; /** * Get current active context * @returns {Object} Current context store diff --git a/types/src/lib/handlers/context-live.d.mts.map b/types/src/lib/handlers/context-live.d.mts.map index a9a9b3f60..4609c1ee4 100644 --- a/types/src/lib/handlers/context-live.d.mts.map +++ b/types/src/lib/handlers/context-live.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"context-live.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/context-live.mjs"],"names":[],"mappings":"AAiEA;;;;;;;;;;;;;GAaG;AACH;IAEE,yBAA0B;IAC1B,uBAA6B;IA2B9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAiCG;IACH,sCAZW,MAAM,GAQJ;QAAC,cAAc,EAAE,MAAM,GAAC,IAAI,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,OAAO,CAAA;KAAC,GAAC,SAAS,CAkDhG;IAoGD;;;;OAIG;IACH,kDAgCC;IAED;;;;;;OAMG;IACH,8BALW,MAAM,WACN,MAAM,GACJ,MAAM,CAwBlB;IAED;;;;;;;;;;;;;OAaG;IACH,gCAZW,MAAM,yBAEN,GAAC,gCAED,MAAM,cACN,OAAO,GAIL,GAAC,CA8Gb;IAED;;;;;OAKG;IACH,qBAJa,MAAM,CAuBlB;IAED;;;;;;;;;;;OAWG;IACH,kCATW,MAAM,GAMJ,MAAM,GAAC,SAAS,CAc5B;IAED;;;;OAIG;IACH,2BAHW,MAAM,QAyBhB;IAED;;;;OAIG;IACH,yBAHa,MAAM,CAclB;;CACD;AAED;;;GAGG;AACH,oDAA2D"} \ No newline at end of file +{"version":3,"file":"context-live.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/context-live.mjs"],"names":[],"mappings":"AAiEA;;;;;;;;;;;;;GAaG;AACH;IAEE,yBAA0B;IAC1B,uBAA6B;IA2B9B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAiCG;IACH,sCAZW,MAAM,GAQJ;QAAC,cAAc,EAAE,MAAM,GAAC,IAAI,CAAC;QAAC,aAAa,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,OAAO,CAAA;KAAC,GAAC,SAAS,CAkDhG;IAqGD;;;;OAIG;IACH,kDAgCC;IAED;;;;;;OAMG;IACH,8BALW,MAAM,WACN,MAAM,GACJ,MAAM,CAwBlB;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,gCAhBW,MAAM,yBAEN,GAAC,gCAED,MAAM,cACN,OAAO,WAIP,OAAO,GAIL,GAAC,CA0Hb;IAED;;;;;OAKG;IACH,qBAJa,MAAM,CAuBlB;IAED;;;;;;;;;;;OAWG;IACH,kCATW,MAAM,GAMJ,MAAM,GAAC,SAAS,CAc5B;IAED;;;;OAIG;IACH,2BAHW,MAAM,QAyBhB;IAED;;;;OAIG;IACH,yBAHa,MAAM,CAclB;;CACD;AAED;;;GAGG;AACH,oDAA2D"} \ No newline at end of file diff --git a/types/src/lib/handlers/metadata.d.mts b/types/src/lib/handlers/metadata.d.mts index 4e5c99e63..82e96766f 100644 --- a/types/src/lib/handlers/metadata.d.mts +++ b/types/src/lib/handlers/metadata.d.mts @@ -241,6 +241,18 @@ export class Metadata extends ComponentBase { * @public */ public caller(): object | null; + /** + * Resolve the wrapper of the API function that called the current function. + * + * The single resolution behind {@link Metadata#caller}: reads `callerWrapper` from the active + * context-manager store. `self.slothlet.lockCaller.caller()` pins exactly this identity, so the + * two can never disagree about who "the caller" is. + * + * @returns {object|null} The calling function's wrapper, or null when there is no module caller + * in context (the function was invoked from the host or outside the API). + * @public + */ + public callerWrapper(): object | null; #private; } import { ComponentBase } from "#factories/component-base"; diff --git a/types/src/lib/handlers/metadata.d.mts.map b/types/src/lib/handlers/metadata.d.mts.map index a72ba89dd..2b8f21078 100644 --- a/types/src/lib/handlers/metadata.d.mts.map +++ b/types/src/lib/handlers/metadata.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"metadata.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/metadata.mjs"],"names":[],"mappings":"AAwBA;;;;;;;;;GASG;AACH,kCAHU,MAAM,CAGsC;AAEtD;;;;;GAKG;AACH;IACC,gCAAqC;IAKrC;;;OAGG;IACH,sBAFW,MAAM,EAKhB;IAVD,4BAA4B;IAC5B,aADW,MAAM,GAAG,IAAI,CACL;IA6KnB;;;;;;;;;;;;;;;OAeG;IACH,0BA+CC;IAED;;;;;OAKG;IACH,0BAJW,WAAS,MAAM,GACb,MAAM,GAAC,IAAI,CAYvB;IAED;;;;;;OAMG;IACH,2BAJW,WAAS,MAAM,GACb,MAAM,CAmElB;IAED;;;;;OAKG;IACH,8BAJW,MAAM,SACN,OAAO,QAKjB;IAED;;;;;;OAMG;IACH,8CAJW,MAAM,SACN,OAAO,QA+CjB;IAED;;;;;;;;OAQG;IACH,kDANW,MAAM,GAAC,MAAM,EAAE,GAAC;YAAO,MAAM,GAAE,MAAM,EAAE;KAAC,QAkFlD;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,iCAJW,MAAM,YACN,MAAM,QA0BhB;IAED;;;;;;;;;OASG;IACH,qCAHW,MAAM,QAMhB;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,gCALW,MAAM,YACN,MAAM,GAAC,MAAM,UACb,OAAO,QAyBjB;IAED;;;;;;;;;;;;;OAaG;IACH,gCANW,MAAM,GACJ,MAAM,CAmBlB;IAED;;;;;;;;;;;;OAYG;IACH,mCAJW,MAAM,QACN,MAAM,GAAC,MAAM,EAAE,QA+BzB;IAED;;;;;;;;;;OAUG;IACH,mBAHa;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,iBAAiB,gBAAK;KAAE,CAe9D;IAED;;;;;;;;;;;;;;;OAeG;IACH,uBAHW;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,iBAAiB,gBAAK;KAAE,QAgC5D;IAED;;;;;;;;;;;;;OAaG;IACH,iBAJW,MAAM,GACJ,OAAO,CAAC,MAAM,GAAC,IAAI,CAAC,CAkChC;IAED;;;;;;;;;;;;;OAaG;IACH,eAHa,MAAM,CAalB;IAED;;;;;;;;;;;;OAYG;IACH,iBAHa,MAAM,GAAC,IAAI,CAOvB;;CACD;8BAz0B6B,2BAA2B"} \ No newline at end of file +{"version":3,"file":"metadata.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/metadata.mjs"],"names":[],"mappings":"AAwBA;;;;;;;;;GASG;AACH,kCAHU,MAAM,CAGsC;AAEtD;;;;;GAKG;AACH;IACC,gCAAqC;IAKrC;;;OAGG;IACH,sBAFW,MAAM,EAKhB;IAVD,4BAA4B;IAC5B,aADW,MAAM,GAAG,IAAI,CACL;IA6KnB;;;;;;;;;;;;;;;OAeG;IACH,0BA+CC;IAED;;;;;OAKG;IACH,0BAJW,WAAS,MAAM,GACb,MAAM,GAAC,IAAI,CAYvB;IAED;;;;;;OAMG;IACH,2BAJW,WAAS,MAAM,GACb,MAAM,CAmElB;IAED;;;;;OAKG;IACH,8BAJW,MAAM,SACN,OAAO,QAKjB;IAED;;;;;;OAMG;IACH,8CAJW,MAAM,SACN,OAAO,QA+CjB;IAED;;;;;;;;OAQG;IACH,kDANW,MAAM,GAAC,MAAM,EAAE,GAAC;YAAO,MAAM,GAAE,MAAM,EAAE;KAAC,QAkFlD;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,iCAJW,MAAM,YACN,MAAM,QA0BhB;IAED;;;;;;;;;OASG;IACH,qCAHW,MAAM,QAMhB;IAED;;;;;;;;;;;;;;;;;;OAkBG;IACH,gCALW,MAAM,YACN,MAAM,GAAC,MAAM,UACb,OAAO,QAyBjB;IAED;;;;;;;;;;;;;OAaG;IACH,gCANW,MAAM,GACJ,MAAM,CAmBlB;IAED;;;;;;;;;;;;OAYG;IACH,mCAJW,MAAM,QACN,MAAM,GAAC,MAAM,EAAE,QA+BzB;IAED;;;;;;;;;;OAUG;IACH,mBAHa;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,iBAAiB,gBAAK;KAAE,CAe9D;IAED;;;;;;;;;;;;;;;OAeG;IACH,uBAHW;QAAE,cAAc,EAAE,MAAM,CAAC;QAAC,iBAAiB,gBAAK;KAAE,QAgC5D;IAED;;;;;;;;;;;;;OAaG;IACH,iBAJW,MAAM,GACJ,OAAO,CAAC,MAAM,GAAC,IAAI,CAAC,CAkChC;IAED;;;;;;;;;;;;;OAaG;IACH,eAHa,MAAM,CAalB;IAED;;;;;;;;;;;;OAYG;IACH,iBAHa,MAAM,GAAC,IAAI,CAOvB;IAED;;;;;;;;;;OAUG;IACH,wBAJa,MAAM,GAAC,IAAI,CAOvB;;CACD;8BAz1B6B,2BAA2B"} \ No newline at end of file diff --git a/types/src/lib/handlers/permission-manager.d.mts.map b/types/src/lib/handlers/permission-manager.d.mts.map index 9d515e7f0..42a04f6e6 100644 --- a/types/src/lib/handlers/permission-manager.d.mts.map +++ b/types/src/lib/handlers/permission-manager.d.mts.map @@ -1 +1 @@ -{"version":3,"file":"permission-manager.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/permission-manager.mjs"],"names":[],"mappings":"AA8KA;;;;;;;GAOG;AACH;IACC;;;;OAIG;IACH,yBAHU,MAAM,CAG8B;IA0I9C;;;;;;OAMG;IACH,sBAJW,MAAM,EAsFhB;IAED;;;;;;;;;;;;;OAaG;IACH,cAVG;QAAqB,MAAM,EAAnB,MAAM;QACO,MAAM,EAAnB,MAAM;QACO,MAAM,EAAnB,MAAM;KACd,kBAAQ,MAAM,GAAC,IAAI,WACX,MAAM,GAAC,IAAI,iBACT,MAAM,CAiDlB;IAED;;;;;;;;;;OAUG;IACH,mBAPW,MAAM,mBACN,MAAM,GAAC,IAAI,GACT,OAAO,CAmCnB;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,mBAZG;QAAqB,MAAM,EAAnB,MAAM;QACO,KAAK,EAAlB,MAAM;QACwB,MAAM,EAApC,MAAM,GAAC,QAAQ,GAAC,OAAO;QACuB,SAAS;KAC/D,kBAAQ,MAAM,GAAC,IAAI,WACX,MAAM,GAAC,IAAI,UACX,MAAM,GAAC,IAAI,GACT,MAAM,CAqClB;IAED;;;;;;;;OAQG;IACH,wBALW,MAAM,mBACN,MAAM,GAAC,IAAI,GACT,OAAO,CAiBnB;IAED;;;;;;;;;;OAUG;IACH,kCALW,MAAM,GAAC,IAAI,aACX,MAAM,mBACN,MAAM,GAAC,IAAI,GACT,MAAM,GAAC,QAAQ,GAAC,OAAO,CA0BnC;IAED;;;;OAIG;IACH,uBAFa,MAAM,CAIlB;IAED;;;;;OAKG;IACH,gCAFa,OAAO,CAOnB;IAuCD;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACH,wBAlBW,MAAM,cAEd;QAAwC,GAAG,EAAnC,CAAS,IAAM,EAAN,MAAM,KAAG,GAAC;QAGQ,OAAO,EAAlC,CAAS,IAAC,EAAD,GAAC,KAAG,GAAC;QACM,MAAM;KAClC,kBAAQ,MAAM,GAAC,IAAI,WACX,CAAS,IAAQ,YAAE,IAAQ,EAAR,KAAK,CAAC,GAAC,CAAC,KAAG,GAAC,GAG7B,IAAI,CA0ChB;IAED;;;;;;;;;;;OAWG;IACH,0BARW,MAAM,mBACN,MAAM,GAAC,IAAI,GACT,OAAO,CAanB;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,0BARW,MAAM,gBACN,GAAC,mBACD,MAAM,GAAC,IAAI,GACT,OAAO,CAiBnB;IAED;;;;;;;OAOG;IACH,mBALW,MAAM,GACJ,OAAO,CAMnB;IAED;;;;;;;;OAQG;IACH,0BALW,MAAM,GACJ,IAAI,CAUhB;IAED;;;;;;;;;;;OAWG;IACH,2BALW,MAAM,GACJ,IAAI,CAUhB;IAED;;;;;;;;;;;OAWG;IACH,uBANW,MAAM,iBACN,MAAM,GACJ,IAAI,CAShB;IAED;;;;;;;;;;;;;;;OAeG;IACH,4BATW,MAAM,cACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,GACT,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,GAAC,CAAA;KAAE,CAAC,CAkCnD;IAED;;;;;;;;;OASG;IACH,yBALW,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,GAAC,CAAA;KAAE,CAAC,GACrC,OAAO,CAAC,IAAI,CAAC,CAMzB;IA4SD;;;;;;;;;;;;;;;OAeG;IACH,wBAXW,MAAM,cACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,YACX;QAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;KAAE,GAAC,IAAI,GAEzB,OAAO,CA2BnB;IAED;;;;;;;;;OASG;IACH,4BANW,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,cAAU,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WAAS,CAAC,GAAC,IAAI,GAAC,SAAS,mBACvF,MAAM,GAAC,IAAI,GACT,OAAO,CASnB;IAyBD;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,0BAjBW,MAAM,cACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,aACX,kBAAkB,GAAC,IAAI,YAGvB;QAAE,cAAc,CAAC,EAAE,OAAO,CAAA;KAAE,GAAC,IAAI,GAG/B,OAAO,CA+BnB;IAED;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,8BAbW,MAAM,GAAC,IAAI,YAEX,MAAM,YACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,GACT,OAAO,CAenB;IAED;;;;;;;;;;;;;;;OAeG;IACH,4BAZW,MAAM,GAAC,IAAI,YACX,MAAM,YACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBAGX,MAAM,GAAC,IAAI,GACT,OAAO,CAMnB;IAED;;;;;;;OAOG;IACH,4BALW,MAAM,GACJ,KAAK,CAAC,MAAM,CAAC,CAazB;IAED;;;;;;;OAOG;IACH,2BALW,MAAM,GACJ,KAAK,CAAC,MAAM,CAAC,CAYzB;IAED;;;;;;;;OAQG;IACH,8BALW,MAAM,GACJ,KAAK,CAAC,MAAM,CAAC,CAazB;IAED;;;;;;OAMG;IACH,UAJa,IAAI,CAQhB;IAcD;;;;;;;;OAQG;IACH,QAJa,IAAI,CAMhB;IAED;;;;;OAKG;IACH,YAJa,OAAO,CAMnB;IAED;;;;;;OAMG;IACH,WAJa,IAAI,CAQhB;IAED;;;;;;OAMG;IACH,aAJa,OAAO,CAMnB;IAED;;;;;;;;OAQG;IACH,uBAJa,OAAO,CAMnB;IAED;;;;;;;;;OASG;IACH,oBAJa,OAAO,CAMnB;IAED;;;;;;;;;;;;OAYG;IACH,mCAVW,MAAM,GAAC,IAAI,GAAC,SAAS,GACnB,OAAO,CAWnB;IAED;;;;;;;;;;;OAWG;IACH,qBANW,OAAO,GACL,IAAI,CAgBhB;IAED;;;;;;OAMG;IAIH,eAPa,KAAK,CAAC,MAAM,CAAC,CAgBzB;IAED;;;;;;;;OAQG;IACH,2BALW,KAAK,CAAC,MAAM,CAAC,GACX,IAAI,CAShB;IAGD;;;;;;;OAOG;IACH,YAJa,OAAO,CAAC,IAAI,CAAC,CAczB;IA4kBD;;;;;;;OAOG;IACH,cAEC;;CACD;;;;;;;;;;;;;;;UAplEa,KAAK,CAAC,GAAC,CAAC,GAAC,IAAI;;;;YACb,MAAM,GAAC,IAAI;;8BAtHK,2BAA2B"} \ No newline at end of file +{"version":3,"file":"permission-manager.d.mts","sourceRoot":"","sources":["../../../../src/lib/handlers/permission-manager.mjs"],"names":[],"mappings":"AA8KA;;;;;;;GAOG;AACH;IACC;;;;OAIG;IACH,yBAHU,MAAM,CAG8B;IA0I9C;;;;;;OAMG;IACH,sBAJW,MAAM,EA8FhB;IAED;;;;;;;;;;;;;OAaG;IACH,cAVG;QAAqB,MAAM,EAAnB,MAAM;QACO,MAAM,EAAnB,MAAM;QACO,MAAM,EAAnB,MAAM;KACd,kBAAQ,MAAM,GAAC,IAAI,WACX,MAAM,GAAC,IAAI,iBACT,MAAM,CAiDlB;IAED;;;;;;;;;;OAUG;IACH,mBAPW,MAAM,mBACN,MAAM,GAAC,IAAI,GACT,OAAO,CAmCnB;IAED;;;;;;;;;;;;;;;;;OAiBG;IACH,mBAZG;QAAqB,MAAM,EAAnB,MAAM;QACO,KAAK,EAAlB,MAAM;QACwB,MAAM,EAApC,MAAM,GAAC,QAAQ,GAAC,OAAO;QACuB,SAAS;KAC/D,kBAAQ,MAAM,GAAC,IAAI,WACX,MAAM,GAAC,IAAI,UACX,MAAM,GAAC,IAAI,GACT,MAAM,CAqClB;IAED;;;;;;;;OAQG;IACH,wBALW,MAAM,mBACN,MAAM,GAAC,IAAI,GACT,OAAO,CAiBnB;IAED;;;;;;;;;;OAUG;IACH,kCALW,MAAM,GAAC,IAAI,aACX,MAAM,mBACN,MAAM,GAAC,IAAI,GACT,MAAM,GAAC,QAAQ,GAAC,OAAO,CA0BnC;IAED;;;;OAIG;IACH,uBAFa,MAAM,CAIlB;IAED;;;;;OAKG;IACH,gCAFa,OAAO,CAOnB;IAuCD;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACH,wBAlBW,MAAM,cAEd;QAAwC,GAAG,EAAnC,CAAS,IAAM,EAAN,MAAM,KAAG,GAAC;QAGQ,OAAO,EAAlC,CAAS,IAAC,EAAD,GAAC,KAAG,GAAC;QACM,MAAM;KAClC,kBAAQ,MAAM,GAAC,IAAI,WACX,CAAS,IAAQ,YAAE,IAAQ,EAAR,KAAK,CAAC,GAAC,CAAC,KAAG,GAAC,GAG7B,IAAI,CA0ChB;IAED;;;;;;;;;;;OAWG;IACH,0BARW,MAAM,mBACN,MAAM,GAAC,IAAI,GACT,OAAO,CAanB;IAED;;;;;;;;;;;;;;;;OAgBG;IACH,0BARW,MAAM,gBACN,GAAC,mBACD,MAAM,GAAC,IAAI,GACT,OAAO,CAiBnB;IAED;;;;;;;OAOG;IACH,mBALW,MAAM,GACJ,OAAO,CAMnB;IAED;;;;;;;;OAQG;IACH,0BALW,MAAM,GACJ,IAAI,CAUhB;IAED;;;;;;;;;;;OAWG;IACH,2BALW,MAAM,GACJ,IAAI,CAUhB;IAED;;;;;;;;;;;OAWG;IACH,uBANW,MAAM,iBACN,MAAM,GACJ,IAAI,CAShB;IAED;;;;;;;;;;;;;;;OAeG;IACH,4BATW,MAAM,cACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,GACT,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,GAAC,CAAA;KAAE,CAAC,CAkCnD;IAED;;;;;;;;;OASG;IACH,yBALW,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,GAAC,CAAA;KAAE,CAAC,GACrC,OAAO,CAAC,IAAI,CAAC,CAMzB;IA4SD;;;;;;;;;;;;;;;OAeG;IACH,wBAXW,MAAM,cACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,YACX;QAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;KAAE,GAAC,IAAI,GAEzB,OAAO,CA2BnB;IAED;;;;;;;;;OASG;IACH,4BANW,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,cAAU,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,WAAS,CAAC,GAAC,IAAI,GAAC,SAAS,mBACvF,MAAM,GAAC,IAAI,GACT,OAAO,CASnB;IAyBD;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,0BAjBW,MAAM,cACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,aACX,kBAAkB,GAAC,IAAI,YAGvB;QAAE,cAAc,CAAC,EAAE,OAAO,CAAA;KAAE,GAAC,IAAI,GAG/B,OAAO,CA+BnB;IAED;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,8BAbW,MAAM,GAAC,IAAI,YAEX,MAAM,YACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,GACT,OAAO,CAenB;IAED;;;;;;;;;;;;;;;OAeG;IACH,4BAZW,MAAM,GAAC,IAAI,YACX,MAAM,YACN,MAAM,mBACN,MAAM,GAAC,IAAI,mBACX,MAAM,GAAC,IAAI,mBAGX,MAAM,GAAC,IAAI,GACT,OAAO,CAMnB;IAED;;;;;;;OAOG;IACH,4BALW,MAAM,GACJ,KAAK,CAAC,MAAM,CAAC,CAazB;IAED;;;;;;;OAOG;IACH,2BALW,MAAM,GACJ,KAAK,CAAC,MAAM,CAAC,CAYzB;IAED;;;;;;;;OAQG;IACH,8BALW,MAAM,GACJ,KAAK,CAAC,MAAM,CAAC,CAazB;IAED;;;;;;OAMG;IACH,UAJa,IAAI,CAQhB;IAcD;;;;;;;;OAQG;IACH,QAJa,IAAI,CAMhB;IAED;;;;;OAKG;IACH,YAJa,OAAO,CAMnB;IAED;;;;;;OAMG;IACH,WAJa,IAAI,CAQhB;IAED;;;;;;OAMG;IACH,aAJa,OAAO,CAMnB;IAED;;;;;;;;OAQG;IACH,uBAJa,OAAO,CAMnB;IAED;;;;;;;;;OASG;IACH,oBAJa,OAAO,CAMnB;IAED;;;;;;;;;;;;OAYG;IACH,mCAVW,MAAM,GAAC,IAAI,GAAC,SAAS,GACnB,OAAO,CAWnB;IAED;;;;;;;;;;;OAWG;IACH,qBANW,OAAO,GACL,IAAI,CAgBhB;IAED;;;;;;OAMG;IAIH,eAPa,KAAK,CAAC,MAAM,CAAC,CAgBzB;IAED;;;;;;;;OAQG;IACH,2BALW,KAAK,CAAC,MAAM,CAAC,GACX,IAAI,CAShB;IAGD;;;;;;;OAOG;IACH,YAJa,OAAO,CAAC,IAAI,CAAC,CAczB;IA4kBD;;;;;;;OAOG;IACH,cAEC;;CACD;;;;;;;;;;;;;;;UA5lEa,KAAK,CAAC,GAAC,CAAC,GAAC,IAAI;;;;YACb,MAAM,GAAC,IAAI;;8BAtHK,2BAA2B"} \ No newline at end of file From 0a303ef594bd4aa2ed1924b54984a02685d40d97 Mon Sep 17 00:00:00 2001 From: Shinrai Date: Mon, 28 Sep 2026 19:40:33 -0700 Subject: [PATCH 19/49] fix(context): attribute live-runtime callers by what is actually in flight Root cause: the live manager resolved the caller from the call stack whenever two or more module calls were suspended, matching a frame only by the exact file a suspended call entered through. V8's async stack traces also carry the `at async` frames of the OUTER suspended calls, so: - a module entered synchronously (a nested leaf, or a lockCaller-pinned callback) whose own file was not a suspended call's was attributed to the outer suspended caller further down the stack (run.boot / run.runHostBootChain), and the "fresh synchronous entry" branch was never reached; - a suspended call resuming in a sibling file of its module fell through to the outer suspended caller the same way. The shared currentWrapper field is also restored in settle order, not entry order, so it could name a caller that had already finished (a synchronous leaf whose unawaited async call settled after it returned), or be cleared while another call was still suspended. The old fallback returned that field, which could hand a resumed module a finished, more privileged caller's rights. New rule (getCallerIdentity, live runtime): 1. A wrapper whose function body is on the JS stack right now (pushed before fn.apply, popped when it returns) is the caller. An await continuation only resumes on an empty stack, so the top entry is provably what is running, however many calls are suspended. 2. No call in flight: the store's baseline (host, or a module-opened run()/scope()'s inherited caller), captured before any call can change the field. 3. One call suspended: that call. 4. Two or more: the stack decides, by each frame's LOCATION (parsed per engine; function names are never read, since a computed method name can spell another module's path). A frame matches a candidate by its entry file, or by lying under the root folder of the candidate's module (base / api.add folder; deepest root wins; slothlet's own frames never match by folder). A frame matching several api paths narrows the answer to them; an outer frame outside that set, or the end of the walk, is AMBIGUOUS and fails closed. No candidate on the stack means the baseline, never the stale field. Two genuinely concurrent calls resuming interleaved still resolve to themselves: neither is on the entered stack, and each resumed flow's own frames name its own call or module. The AMBIGUOUS path is now reachable and covered, so its v8 ignores (context-live, unified-wrapper, api_builder) are removed. Docs: PERMISSIONS.md gains an "Overlapping calls" section with the rule and its limits (files outside every module root, raw cross-module imports, bundles, sourceURL); the stale live-mode lockCaller bullet in HOOKS.md is corrected. Fixes #512 --- .../ext/api/activate.mjs | 77 ++++ .../ext/api/entry.mjs | 44 ++ .../ext/api/resume.mjs | 60 +++ .../ext/api/usage/cache/context.mjs | 29 ++ .../ext/api/views/running.mjs | 26 ++ .../registry.mjs | 51 +++ .../rogue/api/fanout.mjs | 31 ++ .../rogue/api/forge.mjs | 27 ++ .../rogue/api/forged.mjs | 48 ++ .../rogue/api/hold.mjs | 29 ++ .../rogue/api/start.mjs | 27 ++ .../rogue/api/worker.mjs | 29 ++ .../run/boot.mjs | 45 ++ .../run/chain.mjs | 29 ++ .../run/idle.mjs | 27 ++ .../run/kick.mjs | 33 ++ .../run/quick.mjs | 26 ++ .../shared/attempt.mjs | 56 +++ docs/HOOKS.md | 2 +- docs/PERMISSIONS.md | 11 + src/lib/builders/api_builder.mjs | 7 +- src/lib/handlers/context-live.mjs | 369 +++++++++++---- src/lib/handlers/unified-wrapper.mjs | 7 +- ...text-live-suspended-caller.test.vitest.mjs | 424 ++++++++++++++++++ types/src/lib/builders/api_builder.d.mts.map | 2 +- types/src/lib/handlers/context-live.d.mts | 72 ++- types/src/lib/handlers/context-live.d.mts.map | 2 +- .../lib/handlers/unified-wrapper.d.mts.map | 2 +- 28 files changed, 1483 insertions(+), 109 deletions(-) create mode 100644 api_tests/api_test_live_suspended_caller/ext/api/activate.mjs create mode 100644 api_tests/api_test_live_suspended_caller/ext/api/entry.mjs create mode 100644 api_tests/api_test_live_suspended_caller/ext/api/resume.mjs create mode 100644 api_tests/api_test_live_suspended_caller/ext/api/usage/cache/context.mjs create mode 100644 api_tests/api_test_live_suspended_caller/ext/api/views/running.mjs create mode 100644 api_tests/api_test_live_suspended_caller/registry.mjs create mode 100644 api_tests/api_test_live_suspended_caller/rogue/api/fanout.mjs create mode 100644 api_tests/api_test_live_suspended_caller/rogue/api/forge.mjs create mode 100644 api_tests/api_test_live_suspended_caller/rogue/api/forged.mjs create mode 100644 api_tests/api_test_live_suspended_caller/rogue/api/hold.mjs create mode 100644 api_tests/api_test_live_suspended_caller/rogue/api/start.mjs create mode 100644 api_tests/api_test_live_suspended_caller/rogue/api/worker.mjs create mode 100644 api_tests/api_test_live_suspended_caller/run/boot.mjs create mode 100644 api_tests/api_test_live_suspended_caller/run/chain.mjs create mode 100644 api_tests/api_test_live_suspended_caller/run/idle.mjs create mode 100644 api_tests/api_test_live_suspended_caller/run/kick.mjs create mode 100644 api_tests/api_test_live_suspended_caller/run/quick.mjs create mode 100644 api_tests/api_test_live_suspended_caller/shared/attempt.mjs create mode 100644 tests/vitests/suites/context/context-live-suspended-caller.test.vitest.mjs diff --git a/api_tests/api_test_live_suspended_caller/ext/api/activate.mjs b/api_tests/api_test_live_suspended_caller/ext/api/activate.mjs new file mode 100644 index 000000000..df4bcf8fa --- /dev/null +++ b/api_tests/api_test_live_suspended_caller/ext/api/activate.mjs @@ -0,0 +1,77 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_live_suspended_caller/ext/api/activate.mjs + * @Date: 2026-09-28 18:00:00 -07:00 (1790644800) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 18:00:00 -07:00 (1790644800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview The extension's per-entity activation routine (`ext.activate`, invoked by the host + * as `ext.activate.for(id)`). + * + * @module api_test_live_suspended_caller.ext.activate + */ +import { self } from "@cldmv/slothlet/runtime"; +import { afterGate, afterGateCaptured } from "./resume.mjs"; +import { attempt } from "../../shared/attempt.mjs"; + +/** + * Activate for one scenario. A plain function on purpose: the `sibling` scenario hands back a + * promise created in a sibling file, so this file's frame is gone by the time that work resumes. + * @param {string} scenario - Which activation to perform. + * @param {Promise} [gate] - Gate to wait on before acting. + * @returns {Promise<*>} The activation's result. + */ +export function activate(scenario, gate) { + if (scenario === "sibling") return afterGate(gate); + if (scenario === "siblingCaptured") return afterGateCaptured(gate); + if (scenario === "awaited") return awaitSibling(gate); + if (scenario === "hold") return holdThenRender(gate); + return registerThenSettle(scenario); +} + +/** + * Register a pinned render callback (for `pinned`), then settle. + * @param {string} scenario - Activation scenario. + * @returns {Promise} `"activated"`. + */ +async function registerThenSettle(scenario) { + if (scenario === "pinned") { + self.registry.register(self.slothlet.lockCaller(() => attempt(() => self.ext.views.running.render("pinned")))); + self.registry.register( + self.slothlet.lockCaller(() => + attempt( + () => self.ext.usage.cache.context.subscribe(() => {}), + () => "subscribed" + ) + ) + ); + } + await null; + return "activated"; +} + +/** + * Await the sibling file's work from this file, so a frame of this file stays on the resumed stack. + * @param {Promise} gate - Released by the test. + * @returns {Promise>} The sibling's results. + */ +async function awaitSibling(gate) { + return await afterGate(gate); +} + +/** + * Stay suspended on the gate, then reach the extension's own view from this file. + * @param {Promise} gate - Released by the test. + * @returns {Promise} The render result, or `"denied"`. + */ +async function holdThenRender(gate) { + await gate; + return attempt(() => self.ext.views.running.render("held")); +} diff --git a/api_tests/api_test_live_suspended_caller/ext/api/entry.mjs b/api_tests/api_test_live_suspended_caller/ext/api/entry.mjs new file mode 100644 index 000000000..602dab415 --- /dev/null +++ b/api_tests/api_test_live_suspended_caller/ext/api/entry.mjs @@ -0,0 +1,44 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_live_suspended_caller/ext/api/entry.mjs + * @Date: 2026-09-28 18:00:00 -07:00 (1790644800) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 18:00:00 -07:00 (1790644800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview The extension's public entry — the one leaf the host may call directly. + * @module api_test_live_suspended_caller.ext.entry + */ +import { self } from "@cldmv/slothlet/runtime"; +import { attempt } from "../../shared/attempt.mjs"; + +/** + * Synchronously reach the extension's own leaves. Both calls are made before this returns; only + * their results are awaited. + * @returns {Promise>} The render and subscribe results, each possibly `"denied"`. + */ +export function start() { + return Promise.all([ + attempt(() => self.ext.views.running.render("nested")), + attempt( + () => self.ext.usage.cache.context.subscribe(() => {}), + () => "subscribed" + ) + ]); +} + +/** + * Stay suspended on the gate — a second api path of this module in flight at the same time. + * @param {Promise} gate - Released by the test. + * @returns {Promise} `"waited"`. + */ +export async function wait(gate) { + await gate; + return "waited"; +} diff --git a/api_tests/api_test_live_suspended_caller/ext/api/resume.mjs b/api_tests/api_test_live_suspended_caller/ext/api/resume.mjs new file mode 100644 index 000000000..a44aa2c5d --- /dev/null +++ b/api_tests/api_test_live_suspended_caller/ext/api/resume.mjs @@ -0,0 +1,60 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_live_suspended_caller/ext/api/resume.mjs + * @Date: 2026-09-28 18:00:00 -07:00 (1790644800) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 18:00:00 -07:00 (1790644800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview A sibling file of the extension where an activation resumes after its `await`. + * @module api_test_live_suspended_caller.ext.resume + */ +import { self } from "@cldmv/slothlet/runtime"; +import { attempt } from "../../shared/attempt.mjs"; + +/** + * Wait on the gate, then reach the extension's own leaves from this sibling file. + * @param {Promise} gate - Released by the test. + * @returns {Promise>} The render and subscribe results, each possibly `"denied"`. + */ +export async function afterGate(gate) { + await gate; + return Promise.all([ + attempt(() => self.ext.views.running.render("sibling")), + attempt( + () => self.ext.usage.cache.context.subscribe(() => {}), + () => "subscribed" + ) + ]); +} + +/** + * Take references to the extension's own leaves and to the permissions namespace up front, wait on + * the gate, then use them from this sibling file. Each use is gated on whoever is running when it is + * made. + * @param {Promise} gate - Released by the test. + * @returns {Promise>} The render, subscribe and rule-read results, each possibly `"denied"`. + */ +export async function afterGateCaptured(gate) { + const render = self.ext.views.running.render; + const subscribe = self.ext.usage.cache.context.subscribe; + const permissions = self.slothlet.permissions; + await gate; + return Promise.all([ + attempt(() => render("sibling")), + attempt( + () => subscribe(() => {}), + () => "subscribed" + ), + attempt( + () => permissions.global.rulesForPath("ext.views.running.render"), + () => "read" + ) + ]); +} diff --git a/api_tests/api_test_live_suspended_caller/ext/api/usage/cache/context.mjs b/api_tests/api_test_live_suspended_caller/ext/api/usage/cache/context.mjs new file mode 100644 index 000000000..73f5de6ef --- /dev/null +++ b/api_tests/api_test_live_suspended_caller/ext/api/usage/cache/context.mjs @@ -0,0 +1,29 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_live_suspended_caller/ext/api/usage/cache/context.mjs + * @Date: 2026-09-28 18:00:00 -07:00 (1790644800) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 18:00:00 -07:00 (1790644800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview The extension's context cache — reachable by the extension itself only. + * @module api_test_live_suspended_caller.ext.usage.cache.context + */ + +const subscribers = new Set(); + +/** + * Subscribe to updates. + * @param {Function} fn - Subscriber. + * @returns {Function} Unsubscribe. + */ +export function subscribe(fn) { + subscribers.add(fn); + return () => subscribers.delete(fn); +} diff --git a/api_tests/api_test_live_suspended_caller/ext/api/views/running.mjs b/api_tests/api_test_live_suspended_caller/ext/api/views/running.mjs new file mode 100644 index 000000000..f8a37f4fd --- /dev/null +++ b/api_tests/api_test_live_suspended_caller/ext/api/views/running.mjs @@ -0,0 +1,26 @@ +/** + * @Project: @cldmv/slothlet + * @Filename: /api_tests/api_test_live_suspended_caller/ext/api/views/running.mjs + * @Date: 2026-09-28 18:00:00 -07:00 (1790644800) + * @Author: Nate Corcoran + * @Email: + * ----- + * @Last modified by: Nate Corcoran (Shinrai@users.noreply.github.com) + * @Last modified time: 2026-09-28 18:00:00 -07:00 (1790644800) + * ----- + * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved. + */ + +/** + * @fileoverview The extension's view — reachable by the extension itself, never by the host's `run.*`. + * @module api_test_live_suspended_caller.ext.views.running + */ + +/** + * Render a label. + * @param {string} label - What to render. + * @returns {string} `"rendered: