moost 0.6.31 → 0.6.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -6,6 +6,55 @@ let _prostojs_logger = require("@prostojs/logger");
6
6
  let hookable = require("hookable");
7
7
  let wooks = require("wooks");
8
8
 
9
+ //#region packages/moost/src/module-identity.ts
10
+ /**
11
+ * Duplicate-copy detection for moost
12
+ *
13
+ * Mate stores decorator metadata in WeakMaps keyed by constructor
14
+ * reference, and moost keeps module-scoped DI singletons
15
+ * (`sharedMoostInfact`, `moostMate`). If a bundler loads two copies of
16
+ * moost, decorators run against one copy while lookups hit the other,
17
+ * and DI fails with distant "Class is not Injectable" errors. The first
18
+ * loaded copy stamps `globalThis` under a global symbol; any subsequent
19
+ * copy finds the stamp and warns.
20
+ */ const MOOST_MODULE_IDENTITY_KEY = Symbol.for("moost:module-identity");
21
+ function describeCopy(identity) {
22
+ return `${identity.version || "unknown version"} at ${identity.path || "unknown path"}`;
23
+ }
24
+ /**
25
+ * Stamps the global object with this module's identity and warns when
26
+ * another copy of moost was already loaded — decorator metadata and DI
27
+ * singletons do not interoperate between copies.
28
+ *
29
+ * Exported for testability; invoked once at module scope.
30
+ */ function stampModuleIdentity(identity, globalObject = globalThis) {
31
+ const holder = globalObject;
32
+ const existing = holder[MOOST_MODULE_IDENTITY_KEY];
33
+ if (existing) console.warn(`[moost] A second copy of moost was loaded (${describeCopy(existing)}, now ${describeCopy(identity)}). Decorator metadata and DI singletons will NOT interoperate between copies. Check your bundler config / dedupe settings.`);
34
+ else holder[MOOST_MODULE_IDENTITY_KEY] = identity;
35
+ }
36
+ let stamped = false;
37
+ /**
38
+ * One-shot wrapper around `stampModuleIdentity` for this module copy.
39
+ *
40
+ * Invoked at module scope AND from `getMoostMate()`: the package ships
41
+ * `"sideEffects": false`, so a tree-shaking bundler may drop the
42
+ * module-scope invocation when nothing imports from this file — the
43
+ * `getMoostMate()` call keeps the duplicate check reachable in bundled
44
+ * apps (it fires on first decorator use, exactly when per-copy metadata
45
+ * divergence begins), which are the environments that produce duplicate
46
+ * copies in the first place.
47
+ */ function stampOnce() {
48
+ if (stamped) return;
49
+ stamped = true;
50
+ stampModuleIdentity({
51
+ version: "0.6.33",
52
+ path: typeof __filename === "string" ? __filename : require("url").pathToFileURL(__filename).href
53
+ });
54
+ }
55
+ stampOnce();
56
+
57
+ //#endregion
9
58
  //#region packages/moost/src/logger.ts
10
59
  let defaultLogger;
11
60
  function setDefaultLogger(logger) {
@@ -63,6 +112,74 @@ function runPipes(pipes, initialValue, metas, level) {
63
112
  return v;
64
113
  }
65
114
 
115
+ //#endregion
116
+ //#region packages/moost/src/metadata/diagnostics.ts
117
+ let sourceRefs = [];
118
+ /** Dereferences the registered sources, pruning refs whose target was collected. */ function scanLiveSources() {
119
+ const live = [];
120
+ const keep = [];
121
+ for (const ref of sourceRefs) {
122
+ const source = ref.deref();
123
+ if (source) {
124
+ live.push(source);
125
+ keep.push(ref);
126
+ }
127
+ }
128
+ sourceRefs = keep;
129
+ return live;
130
+ }
131
+ /**
132
+ * Registers a Moost app as a source for DI diagnostics scans (D4 scope
133
+ * hints). Held via `WeakRef` so a dropped app never leaks; registering the
134
+ * same app again is a no-op.
135
+ */ function registerDiagnosticsSource(source) {
136
+ if (scanLiveSources().includes(source)) return;
137
+ sourceRefs.push(new WeakRef(source));
138
+ }
139
+ /**
140
+ * Scans the registered apps' controllers for a class-scoped `@Provide`
141
+ * carrying `token` and returns the provider controller class names. Both
142
+ * string and symbol keys are checked — symbol class-tokens match by identity
143
+ * because infact keys them via `Symbol.for` (same class source, same symbol).
144
+ */ function findTokenProviders(token) {
145
+ const providers = [];
146
+ for (const source of scanLiveSources()) for (const entry of source.getControllersOverview()) if (Reflect.ownKeys(entry.meta?.provide ?? {}).includes(token) && !providers.includes(entry.type.name)) providers.push(entry.type.name);
147
+ return providers;
148
+ }
149
+ const CLASS_SOURCE_RE = /^class\s+([\w$]+)/;
150
+ /** Renders a provide token readably: strings as-is, class-source symbols by class name. */ function stringifyToken(token) {
151
+ if (typeof token === "string") return token;
152
+ const description = token.description ?? token.toString();
153
+ const match = CLASS_SOURCE_RE.exec(description);
154
+ return match ? match[1] : description;
155
+ }
156
+ /**
157
+ * D4: when an unresolved `@Inject(token)` IS provided somewhere — via a
158
+ * class-scoped `@Provide` on a sibling controller — teaches the actual
159
+ * scoping rule (providers flow parent → child, never to siblings) and names
160
+ * both remedies. Returns `undefined` when no provider was found.
161
+ */ function formatScopeHint(token, providerNames) {
162
+ if (providerNames.length === 0) return;
163
+ const subject = providerNames.length === 1 ? `sibling controller ${providerNames[0]}` : `sibling controllers ${providerNames.join(", ")}`;
164
+ return `Token "${stringifyToken(token)}" is provided on ${subject} via class-scoped @Provide, which is only visible to that controller's own subtree (providers flow parent → child through @ImportController, never to siblings). Move it to app.setProvideRegistry(...) or to a common parent @ImportController.`;
165
+ }
166
+ /**
167
+ * D2: renders an infact instantiation error with the consumer-side context
168
+ * carried by `detail` — parameter position/label/type plus the resolution
169
+ * hierarchy — as one multi-line message (single `logger.error` call). The
170
+ * `import type` hint is already appended to `message` upstream by infact and
171
+ * is deliberately not duplicated here.
172
+ */ function formatInfactErrorContext(targetClassName, message, detail) {
173
+ let head = `Failed to instantiate ${targetClassName}`;
174
+ if (typeof detail.paramIndex === "number") {
175
+ const qualifiers = [detail.paramLabel === void 0 ? "" : `label "${detail.paramLabel}"`, detail.paramTypeName === void 0 ? "" : `type ${detail.paramTypeName}`].filter(Boolean).join(", ");
176
+ head += `: constructor parameter #${detail.paramIndex}${qualifiers ? ` (${qualifiers})` : ""}`;
177
+ }
178
+ const lines = [`${head} — ${message}`];
179
+ if (detail.hierarchy && detail.hierarchy.length > 0) lines.push(` Hierarchy: ${detail.hierarchy.join(" → ")}`);
180
+ return lines.join("\n");
181
+ }
182
+
66
183
  //#endregion
67
184
  //#region packages/moost/src/metadata/moost-metadata.ts
68
185
  const METADATA_WORKSPACE = "moost";
@@ -86,6 +203,7 @@ const moostMate = new _prostojs_mate.Mate(METADATA_WORKSPACE, {
86
203
  }
87
204
  });
88
205
  /** Returns the shared `Mate` instance operating in the `'moost'` metadata workspace. */ function getMoostMate() {
206
+ stampOnce();
89
207
  return moostMate;
90
208
  }
91
209
 
@@ -125,6 +243,66 @@ const scopeVarsMap = /* @__PURE__ */ new Map();
125
243
  */ function getInfactScopeVars(name) {
126
244
  return scopeVarsMap.get(name);
127
245
  }
246
+ /** Applies the `setInfactLoggingOptions` filters to an Infact event. */ function shouldLogInfactEvent(event, targetClass) {
247
+ if (event === "warn") return !!loggingOptions.warn;
248
+ if (event === "error") return !!loggingOptions.error;
249
+ const scope = getMoostMate().read(targetClass)?.injectable || "SINGLETON";
250
+ return loggingOptions.newInstance !== false && (loggingOptions.newInstance === scope || loggingOptions.newInstance === "SINGLETON" && scope === true);
251
+ }
252
+ /** Renders the DI resolution-hierarchy breadcrumb (`⋱ A → B`). */ function formatHierarchy(args) {
253
+ return `⋱ ${args?.map(String).join(" → ") || ""}`;
254
+ }
255
+ function formatNewInstanceArg(a) {
256
+ switch (typeof a) {
257
+ case "number":
258
+ case "boolean": return `${a}`;
259
+ case "string": return `"${a.slice(0, 1)}..."`;
260
+ case "object":
261
+ if (Array.isArray(a)) return `[${a.length}]`;
262
+ if ((0, _prostojs_mate.getConstructor)(a)) return (0, _prostojs_mate.getConstructor)(a).name;
263
+ return "{}";
264
+ default: return "*";
265
+ }
266
+ }
267
+ /** Renders the resolved constructor args for the `new-instance` log line. */ function formatNewInstanceParams(args) {
268
+ return args?.map((a) => `${formatNewInstanceArg(a)}`).join(", ") || "";
269
+ }
270
+ /**
271
+ * Renders an `'error'` event. Without `detail` (everything the installed
272
+ * @prostojs/infact@0.4.1 ever produces) the output is exactly the legacy
273
+ * format. With `detail` (newer infact) the D2 consumer context is rendered
274
+ * and, when the failing `@Inject` token is class-provided on a sibling
275
+ * controller of a registered app, the D4 scope hint is appended.
276
+ */ function renderInfactError(targetClass, message, args, detail) {
277
+ if (!detail) return `Failed to instantiate ${`${targetClass.name}`}. ${message} ${formatHierarchy(args)}`;
278
+ let text = formatInfactErrorContext(targetClass.name, message, detail);
279
+ if (detail.injectToken !== void 0) {
280
+ const hint = formatScopeHint(detail.injectToken, findTokenProviders(detail.injectToken));
281
+ if (hint) text += `\n ${hint}`;
282
+ }
283
+ return text;
284
+ }
285
+ /**
286
+ * Infact event sink. Declared with the forward-compatible 5-param signature:
287
+ * the installed @prostojs/infact (0.4.1) calls it with 4 args (never passes
288
+ * `detail`), while newer infact versions pass a `detail` payload on `'error'`
289
+ * events that unlocks the rich D2/D4 rendering (see `renderInfactError`).
290
+ *
291
+ * Exported for tests — since 0.4.1 never passes `detail` at runtime, the
292
+ * detail-driven path is exercised by invoking this function directly.
293
+ */ function onInfactEvent(event, targetClass, message, args, detail) {
294
+ if (!shouldLogInfactEvent(event, targetClass)) return;
295
+ let logger;
296
+ try {
297
+ logger = event === "error" ? getDefaultLogger(INFACT_BANNER) : (0, _wooksjs_event_core.useLogger)(INFACT_BANNER);
298
+ } catch {
299
+ logger = getDefaultLogger(INFACT_BANNER);
300
+ }
301
+ const instance = `${targetClass.name}`;
302
+ if (event === "new-instance") logger.info(`new ${instance}(${formatNewInstanceParams(args)})`);
303
+ else if (event === "warn") logger.warn(`${instance} - ${message} ${formatHierarchy(args)}`);
304
+ else logger.error(renderInfactError(targetClass, message, args, detail));
305
+ }
128
306
  /**
129
307
  * Get Infact instance (used for Dependency Injections)
130
308
  */ function getNewMoostInfact() {
@@ -168,58 +346,7 @@ const scopeVarsMap = /* @__PURE__ */ new Map();
168
346
  }, "PROP");
169
347
  },
170
348
  storeProvideRegByInstance: true,
171
- on: (event, targetClass, message, args) => {
172
- switch (event) {
173
- case "new-instance": {
174
- const scope = getMoostMate().read(targetClass)?.injectable || "SINGLETON";
175
- if (loggingOptions.newInstance === false || !(loggingOptions.newInstance === scope || loggingOptions.newInstance === "SINGLETON" && scope === true)) return;
176
- break;
177
- }
178
- case "warn":
179
- if (!loggingOptions.warn) return;
180
- break;
181
- case "error":
182
- if (!loggingOptions.error) return;
183
- break;
184
- default:
185
- }
186
- let logger;
187
- try {
188
- logger = event === "error" ? getDefaultLogger(INFACT_BANNER) : (0, _wooksjs_event_core.useLogger)(INFACT_BANNER);
189
- } catch {
190
- logger = getDefaultLogger(INFACT_BANNER);
191
- }
192
- const instance = `${targetClass.name}`;
193
- switch (event) {
194
- case "new-instance": {
195
- const params = args?.map((a) => {
196
- switch (typeof a) {
197
- case "number":
198
- case "boolean": return `${a}`;
199
- case "string": return `"${a.slice(0, 1)}..."`;
200
- case "object":
201
- if (Array.isArray(a)) return `[${a.length}]`;
202
- if ((0, _prostojs_mate.getConstructor)(a)) return (0, _prostojs_mate.getConstructor)(a).name;
203
- return "{}";
204
- default: return "*";
205
- }
206
- }).map((a) => `${a}`).join(", ") || "";
207
- logger.info(`new ${instance}(${params})`);
208
- break;
209
- }
210
- case "warn": {
211
- const hier = `⋱ ${args?.map(String).join(" → ") || ""}`;
212
- logger.warn(`${instance} - ${message} ${hier}`);
213
- break;
214
- }
215
- case "error": {
216
- const hier = `⋱ ${args?.map(String).join(" → ") || ""}`;
217
- logger.error(`Failed to instantiate ${instance}. ${message} ${hier}`);
218
- break;
219
- }
220
- default: break;
221
- }
222
- }
349
+ on: onInfactEvent
223
350
  });
224
351
  }
225
352
 
@@ -482,11 +609,20 @@ function nextScopeId() {
482
609
  ...Object.getOwnPropertyNames(instance)
483
610
  ])].filter((m) => typeof instance[m] !== "function");
484
611
  }
485
- const fnProto = Object.getPrototypeOf(Function);
612
+ /**
613
+ * Walks the user-defined ancestor classes of a constructor, closest first.
614
+ * Terminates at `Function.prototype` (it is a function but has no `.prototype`).
615
+ */ function* ancestorsOf(classConstructor) {
616
+ let parent = Object.getPrototypeOf(classConstructor);
617
+ while (typeof parent === "function" && parent.prototype) {
618
+ yield parent;
619
+ parent = Object.getPrototypeOf(parent);
620
+ }
621
+ }
486
622
  function getParentProps(constructor) {
487
- const parent = Object.getPrototypeOf(constructor);
488
- if (typeof parent === "function" && parent !== fnProto && parent !== constructor && parent.prototype) return [...getParentProps(parent), ...Object.getOwnPropertyNames(parent.prototype)];
489
- return [];
623
+ const props = [];
624
+ for (const parent of ancestorsOf(constructor)) props.unshift(...Object.getOwnPropertyNames(parent.prototype));
625
+ return props;
490
626
  }
491
627
 
492
628
  //#endregion
@@ -644,11 +780,20 @@ function ImportController(prefix, controller, provide) {
644
780
  return getMoostMate().apply(getMoostMate().decorate("paramSource", "ROUTE"), getMoostMate().decorate("paramName", name), Resolve(() => (0, _wooksjs_event_core.useRouteParams)().get(name), name));
645
781
  }
646
782
  /**
647
- * Get Parsed Params from url parh
783
+ * Get Parsed Params from url path
784
+ *
785
+ * Stamps `paramSource: 'ROUTE'` metadata (like `@Param(name)` does), so
786
+ * source-aware pipes (e.g. coercion pipes that only coerce string-transport
787
+ * input) recognize the whole params object as route input.
788
+ *
789
+ * Tip: type the argument with an interface-based DTO (e.g. an atscript `.as`
790
+ * interface) — interfaces emit as `declare class`, so the design type survives
791
+ * every metadata toolchain, unlike scalar type aliases which only survive
792
+ * syntactic emitters.
648
793
  * @decorator
649
794
  * @paramType object
650
795
  */ function Params() {
651
- return Resolve(() => (0, _wooksjs_event_core.useRouteParams)().params, "params");
796
+ return getMoostMate().apply(getMoostMate().decorate("paramSource", "ROUTE"), Resolve(() => (0, _wooksjs_event_core.useRouteParams)().params, "params"));
652
797
  }
653
798
  /**
654
799
  * Provide Const Value
@@ -1381,6 +1526,51 @@ function getIterceptorHandlerFactory(interceptors, getTargetInstance, pipes) {
1381
1526
  return () => new InterceptorHandler(precomputedHandlers);
1382
1527
  }
1383
1528
 
1529
+ //#endregion
1530
+ //#region packages/moost/src/binding/param-audit.ts
1531
+ /**
1532
+ * Resolves the effective param-audit mode: an explicit setting wins; otherwise
1533
+ * `'error'` in dev (`NODE_ENV !== 'production'`) and `'warn'` in production.
1534
+ */ function resolveParamAuditMode(setting) {
1535
+ if (setting) return setting;
1536
+ return process.env.NODE_ENV === "production" ? "warn" : "error";
1537
+ }
1538
+ /**
1539
+ * True when the param carries no explicit resolution (`@Resolve`-based
1540
+ * decorators, `@Inject`, `@Circular`) and its emitted design type is unusable
1541
+ * for DI — `Object` (class erased by `import type`, or an interface/union) or
1542
+ * `undefined` (circular import). Such a param falls through to
1543
+ * `infact.get(param.type)` and fails there or silently injects `undefined`.
1544
+ */ function isBrokenFallThrough(param) {
1545
+ return !param.resolver && !param.inject && !param.circular && (param.type === Object || param.type === void 0);
1546
+ }
1547
+ function buildMessage(ctx, index, param) {
1548
+ const where = `[moost] ${ctx.className}.${ctx.methodName ?? "constructor"} parameter #${index}`;
1549
+ if (!ctx.methodName) return param.type === void 0 ? `${where} has no emitted design type — usually a circular import. Use @Circular(() => Type).` : `${where} has type Object — its class was likely imported with 'import type', or it is an interface/union type. Use a value import, an explicit @Inject(token) or @Resolve(), or mark it @Optional() to silence.`;
1550
+ return param.type === void 0 ? `${where} has no emitted design type (usually a circular import) and no param decorator — it will resolve to undefined at event time. Add a param decorator (e.g. @Param/@Body) or @Resolve().` : `${where} has type Object and no param decorator — it will resolve to undefined at event time. Add a param decorator (e.g. @Param/@Body) or @Resolve(); if the type is a class imported with 'import type', use a value import.`;
1551
+ }
1552
+ function severityOf(ctx, param) {
1553
+ if (ctx.methodName) return "warn-only";
1554
+ return param.optional || param.nullable ? "warn-only" : "fatal-capable";
1555
+ }
1556
+ /**
1557
+ * D1 bind-time param audit: returns a finding for every param that will fall
1558
+ * through to DI class-instantiation with a broken emitted type (`Object` or
1559
+ * `undefined`) and no explicit resolution. Pure — the caller decides how to
1560
+ * log/throw (see `Moost.init()`).
1561
+ */ function auditParams(params, ctx) {
1562
+ const findings = [];
1563
+ for (const [index, param] of (params || []).entries()) if (param && isBrokenFallThrough(param)) findings.push({
1564
+ message: buildMessage(ctx, index, param),
1565
+ severity: severityOf(ctx, param)
1566
+ });
1567
+ return findings;
1568
+ }
1569
+ /** Formats the aggregate error thrown by `init()` when fatal-capable findings exist. */ function formatParamAuditError(findings) {
1570
+ const list = findings.map((f) => ` - ${f.message}`).join("\n");
1571
+ return `[moost] DI param audit failed (${findings.length} finding${findings.length === 1 ? "" : "s"}):\n${list}\nSet diagnostics: { paramTypes: 'warn' } (or 'off') on Moost options to downgrade.`;
1572
+ }
1573
+
1384
1574
  //#endregion
1385
1575
  //#region packages/moost/src/binding/bind-controller.ts
1386
1576
  async function bindControllerMethods(options) {
@@ -1403,6 +1593,10 @@ async function bindControllerMethods(options) {
1403
1593
  };
1404
1594
  for (const method of methods) {
1405
1595
  const methodMeta = getMoostMate().read(fakeInstance, method) || {};
1596
+ if (options.reportParamAudit && (methodMeta.moostInit || methodMeta.handlers?.length)) options.reportParamAudit(auditParams(methodMeta.params, {
1597
+ className: classConstructor.name,
1598
+ methodName: method
1599
+ }));
1406
1600
  if (methodMeta.moostInit) {
1407
1601
  if (meta.injectable === "FOR_EVENT") throw new Error(`@MoostInit is not allowed on a FOR_EVENT controller (${classConstructor.name}.${String(method)}). Init hooks run once at boot on the SINGLETON instance; FOR_EVENT controllers have no init-time instance.`);
1408
1602
  const initArgsPipes = (methodMeta.params || []).map((p) => ({
@@ -1473,6 +1667,93 @@ async function bindControllerMethods(options) {
1473
1667
  return controllerOverview;
1474
1668
  }
1475
1669
 
1670
+ //#endregion
1671
+ //#region packages/moost/src/binding/inheritance-audit.ts
1672
+ /** Counts handler metas on the class' own prototype methods (its inherit rules applied). */ function countHandlers(classConstructor) {
1673
+ const mate = getMoostMate();
1674
+ let count = 0;
1675
+ for (const name of Object.getOwnPropertyNames(classConstructor.prototype)) if (name !== "constructor") count += mate.read(classConstructor, name)?.handlers?.length ?? 0;
1676
+ return count;
1677
+ }
1678
+ /**
1679
+ * Names the classes strictly between `classConstructor` and `ancestor` that
1680
+ * carry no truthy `@Inherit` — the links that break the metadata bridge
1681
+ * (both `@Inherit()` and the parent-params fallback cross one level at a time).
1682
+ */ function brokenLinksBetween(classConstructor, ancestor) {
1683
+ const mate = getMoostMate();
1684
+ const links = [];
1685
+ for (const parent of ancestorsOf(classConstructor)) {
1686
+ if (parent === ancestor) break;
1687
+ if (!mate.read(parent)?.inherit) links.push(parent.name);
1688
+ }
1689
+ return links;
1690
+ }
1691
+ /**
1692
+ * Route-drop trap: the registered class contributed 0 handlers while an
1693
+ * ancestor defines some — its routes silently 404. Fires when the class
1694
+ * carries no `@Inherit` decision (routes never flow by default) AND when it
1695
+ * carries `@Inherit()` but nothing flowed (that combination is always a bridge
1696
+ * broken by intermediate classes without `@Inherit()` of their own). An
1697
+ * explicit `@Inherit(false)` is a deliberate opt-out and stays silent.
1698
+ */ function auditRouteDrop(input) {
1699
+ if (input.ownHandlersCount > 0 || input.classMeta?.inherit === false) return;
1700
+ for (const ancestor of ancestorsOf(input.classConstructor)) {
1701
+ const count = countHandlers(ancestor);
1702
+ if (count > 0) return {
1703
+ severity: "warn-only",
1704
+ message: routeDropMessage(input, ancestor, count)
1705
+ };
1706
+ }
1707
+ }
1708
+ function routeDropMessage(input, ancestor, count) {
1709
+ const name = input.classConstructor.name;
1710
+ if (input.classMeta?.inherit) {
1711
+ const links = brokenLinksBetween(input.classConstructor, ancestor);
1712
+ const brokenBy = links.length > 0 ? `the intermediate class(es) ${links.join(", ")} carry no @Inherit()` : "the inherit chain between them is broken";
1713
+ return `[moost] ${name} has @Inherit() but registered 0 route handler(s) while its ancestor ${ancestor.name} defines ${count} — @Inherit() bridges one level at a time and ${brokenBy}. Add @Inherit() to each intermediate class or re-declare the routes.`;
1714
+ }
1715
+ return `[moost] ${name} extends ${ancestor.name} which defines ${count} route handler(s), but ${name} registered 0 and has no @Inherit() — parent routes are not inherited without it. Add @Inherit() or re-declare the routes.`;
1716
+ }
1717
+ /**
1718
+ * Lost-constructor-params trap: a DI-instantiated class whose effective params
1719
+ * metadata is absent while a decorated ancestor declares constructor params.
1720
+ * Covers two shapes:
1721
+ * - the class carries no Moost metadata at all (undecorated subclass registered
1722
+ * as a controller) — it is not even seen as injectable;
1723
+ * - the class is decorated but an undecorated intermediate class breaks the
1724
+ * automatic parent-params fallback (metadata reads walk one level only).
1725
+ * An own zero-param constructor emits `params: []` and is a deliberate choice —
1726
+ * only truly absent params fire.
1727
+ */ function auditLostCtorParams(input) {
1728
+ if (!input.diInstantiated || input.classMeta?.params) return;
1729
+ const mate = getMoostMate();
1730
+ for (const ancestor of ancestorsOf(input.classConstructor)) {
1731
+ const count = mate.read(ancestor)?.params?.length ?? 0;
1732
+ if (count > 0) return {
1733
+ severity: "warn-only",
1734
+ message: lostCtorParamsMessage(input, ancestor, count)
1735
+ };
1736
+ }
1737
+ }
1738
+ function lostCtorParamsMessage(input, ancestor, count) {
1739
+ const name = input.classConstructor.name;
1740
+ if (input.classMeta) {
1741
+ const links = brokenLinksBetween(input.classConstructor, ancestor);
1742
+ const intermediates = links.length > 0 ? ` (${links.join(", ")})` : "";
1743
+ return `[moost] ${name} has no constructor param metadata, but its ancestor ${ancestor.name} declares ${count} constructor param(s) — the inherited constructor would be invoked with zero resolved arguments (dependencies undefined). The parent-params fallback crosses one level at a time: add @Inherit() to the intermediate class(es)${intermediates} or re-declare the constructor with its param decorators.`;
1744
+ }
1745
+ return `[moost] ${name} extends ${ancestor.name} whose constructor declares ${count} DI param(s), but ${name} carries no Moost metadata of its own — it is not seen as injectable and the constructor params will not resolve. Add @Inherit() to ${name} or re-declare the constructor.`;
1746
+ }
1747
+ /**
1748
+ * Bind-time inheritance audit (IMPROVEMENTS.md §3): detects the two real traps
1749
+ * of subclassing decorated classes — parent routes silently dropping without a
1750
+ * working `@Inherit()` bridge, and parent constructor params silently not
1751
+ * resolving. Pure and warn-only: findings join the D1 collector and
1752
+ * `Moost.init()` logs them as warnings (they never reject `init()`).
1753
+ */ function auditInheritance(input) {
1754
+ return [auditRouteDrop(input), auditLostCtorParams(input)].filter((f) => f !== void 0);
1755
+ }
1756
+
1476
1757
  //#endregion
1477
1758
  //#region packages/moost/src/moost.ts
1478
1759
  function _define_property(obj, key, value) {
@@ -1486,6 +1767,16 @@ function _define_property(obj, key, value) {
1486
1767
  return obj;
1487
1768
  }
1488
1769
  /**
1770
+ * Detects the object registration form of `registerControllers`: a plain
1771
+ * object (no class prototype) carrying a `controllers` array. Controller
1772
+ * instances are class instances and never match, so all pre-existing
1773
+ * registration forms bind unchanged.
1774
+ */ function isControllersGroup(entry) {
1775
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return false;
1776
+ const proto = Object.getPrototypeOf(entry);
1777
+ return (proto === Object.prototype || proto === null) && Array.isArray(entry.controllers);
1778
+ }
1779
+ /**
1489
1780
  * ## Moost
1490
1781
  * Main moostjs class that serves as a shell for Moost Adapters
1491
1782
  *
@@ -1595,10 +1886,18 @@ function _define_property(obj, key, value) {
1595
1886
  if (constructor) this.setProvideRegistry((0, _prostojs_infact.createProvideRegistry)([constructor, () => a]));
1596
1887
  if (typeof a.getProvideRegistry === "function") this.setProvideRegistry(a.getProvideRegistry());
1597
1888
  }
1598
- this.unregisteredControllers.unshift(this);
1599
- await this.bindControllers();
1889
+ this.unregisteredControllers.unshift({ controller: this });
1890
+ let auditError;
1891
+ try {
1892
+ await this.bindControllers();
1893
+ } finally {
1894
+ registerDiagnosticsSource(this);
1895
+ auditError = this.flushParamAudit();
1896
+ }
1897
+ if (auditError) throw new Error(auditError);
1600
1898
  await this.runInitHooks();
1601
1899
  for (const a of this.adapters) await (a.onInit && a.onInit(this));
1900
+ this.initialized = true;
1602
1901
  }
1603
1902
  /**
1604
1903
  * Runs every `@MoostInit`-decorated controller method exactly once, after all
@@ -1617,6 +1916,31 @@ function _define_property(obj, key, value) {
1617
1916
  await instance[hook.method](...args);
1618
1917
  });
1619
1918
  }
1919
+ /**
1920
+ * D1 audit of a DI-instantiated controller class' constructor params.
1921
+ * Findings are collected for `init()` to flush. Returns `true` when the
1922
+ * bind-time SINGLETON instantiation must be skipped: in `'error'` mode a
1923
+ * fatal-capable finding guarantees `init()` rejects with the aggregated
1924
+ * audit error (naming class and param), which the generic infact
1925
+ * instantiation error would otherwise preempt.
1926
+ */ collectConstructorAudit(className, classMeta) {
1927
+ if (this.paramAuditMode === "off" || !classMeta?.injectable) return false;
1928
+ const findings = auditParams(classMeta.params, { className });
1929
+ this.paramAuditFindings.push(...findings);
1930
+ return this.paramAuditMode === "error" && findings.some((f) => f.severity === "fatal-capable");
1931
+ }
1932
+ /**
1933
+ * Flushes D1 findings collected during binding: every finding is logged as a
1934
+ * warning; in `'error'` mode, fatal-capable findings are folded into one
1935
+ * aggregate error message listing all of them, returned for `init()` to
1936
+ * throw (`init()` owns the precedence between this and a bind error).
1937
+ */ flushParamAudit() {
1938
+ const findings = this.paramAuditFindings;
1939
+ this.paramAuditFindings = [];
1940
+ for (const f of findings) this.logger.warn(f.message);
1941
+ const fatal = findings.filter((f) => f.severity === "fatal-capable");
1942
+ if (fatal.length > 0 && this.paramAuditMode === "error") return formatParamAuditError(fatal);
1943
+ }
1620
1944
  async bindControllers() {
1621
1945
  const thisMeta = getMoostMate().read(this);
1622
1946
  const provide = {
@@ -1627,15 +1951,8 @@ function _define_property(obj, key, value) {
1627
1951
  ...thisMeta?.replace,
1628
1952
  ...this.replace
1629
1953
  };
1630
- for (const _controller of this.unregisteredControllers) {
1631
- let newPrefix;
1632
- let controller = _controller;
1633
- if (Array.isArray(_controller) && typeof _controller[0] === "string") {
1634
- newPrefix = _controller[0];
1635
- controller = _controller[1];
1636
- }
1637
- await this.bindController(controller, provide, replace, this.options?.globalPrefix || "", newPrefix);
1638
- }
1954
+ const globalPrefix = this.options?.globalPrefix || "";
1955
+ for (const { controller, prependPrefix, replaceOwnPrefix } of this.unregisteredControllers) await this.bindController(controller, provide, replace, prependPrefix ? `${globalPrefix}/${prependPrefix}` : globalPrefix, replaceOwnPrefix);
1639
1956
  this.unregisteredControllers = [];
1640
1957
  }
1641
1958
  async bindController(controller, provide, replace, globalPrefix, replaceOwnPrefix) {
@@ -1650,7 +1967,7 @@ function _define_property(obj, key, value) {
1650
1967
  replace,
1651
1968
  customData: { pipes }
1652
1969
  };
1653
- if (isControllerConsructor && (classMeta?.injectable === "SINGLETON" || classMeta?.injectable === true)) await (0, _wooksjs_event_core.createEventContext)({ logger: this.logger }, async () => {
1970
+ if (!(isControllerConsructor && this.collectConstructorAudit(controller.name, classMeta)) && isControllerConsructor && (classMeta?.injectable === "SINGLETON" || classMeta?.injectable === true)) await (0, _wooksjs_event_core.createEventContext)({ logger: this.logger }, async () => {
1654
1971
  setControllerContext(this, "bindController", "", { prefix: computedPrefix });
1655
1972
  instance = await infact.get(controller, infactOpts);
1656
1973
  });
@@ -1660,7 +1977,7 @@ function _define_property(obj, key, value) {
1660
1977
  }
1661
1978
  const getInstance = instance ? () => instance : async () => await infact.get(controller, { ...infactOpts });
1662
1979
  const classConstructor = (0, _prostojs_mate.isConstructor)(controller) ? controller : (0, _prostojs_mate.getConstructor)(controller);
1663
- this.controllersOverview.push(await bindControllerMethods({
1980
+ const controllerOverview = await bindControllerMethods({
1664
1981
  getInstance,
1665
1982
  classConstructor,
1666
1983
  adapters: this.adapters,
@@ -1672,7 +1989,15 @@ function _define_property(obj, key, value) {
1672
1989
  replace: classMeta?.replace,
1673
1990
  logger: this.logger,
1674
1991
  moostInstance: this,
1675
- registerInitHook: (hook) => this.initHooks.push(hook)
1992
+ registerInitHook: (hook) => this.initHooks.push(hook),
1993
+ reportParamAudit: this.paramAuditMode === "off" ? void 0 : (findings) => this.paramAuditFindings.push(...findings)
1994
+ });
1995
+ this.controllersOverview.push(controllerOverview);
1996
+ if (this.inheritanceAuditMode !== "off") this.paramAuditFindings.push(...auditInheritance({
1997
+ classConstructor,
1998
+ classMeta,
1999
+ ownHandlersCount: controllerOverview.handlers.length,
2000
+ diInstantiated: isControllerConsructor
1676
2001
  }));
1677
2002
  this.handlerOverviewIndex = void 0;
1678
2003
  if (classMeta?.importController) {
@@ -1740,9 +2065,18 @@ function _define_property(obj, key, value) {
1740
2065
  }
1741
2066
  /**
1742
2067
  * Register new entries to provide as dependency injections
2068
+ *
2069
+ * Ordering rule: call this **before `init()`**. `init()` snapshots the
2070
+ * provide registry once when binding controllers, so entries added later are
2071
+ * never seen by already-bound controllers (sibling registration order does
2072
+ * not matter — only the before/after-`init()` boundary does). To scope
2073
+ * providers to part of the app, use class-level `@Provide` on a parent
2074
+ * controller — it flows parent → child through `@ImportController`, never
2075
+ * to siblings.
1743
2076
  * @param provide - Provide Registry (use createProvideRegistry from '\@prostojs/infact')
1744
2077
  * @returns
1745
2078
  */ setProvideRegistry(provide) {
2079
+ if (this.initialized) this.logger.warn("[moost] setProvideRegistry() called after init() — already-bound controllers will not see these providers. Register providers before init(), or provide them via @Provide on a parent controller.");
1746
2080
  this.provide = {
1747
2081
  ...this.provide,
1748
2082
  ...provide
@@ -1751,9 +2085,14 @@ function _define_property(obj, key, value) {
1751
2085
  }
1752
2086
  /**
1753
2087
  * Register replace classes to provide as dependency injections
2088
+ *
2089
+ * Ordering rule: call this **before `init()`**. `init()` snapshots the
2090
+ * replace registry once when binding controllers, so replacements added
2091
+ * later are never seen by already-bound controllers.
1754
2092
  * @param replace - Replace Registry (use createReplaceRegistry from '\@prostojs/infact')
1755
2093
  * @returns
1756
2094
  */ setReplaceRegistry(replace) {
2095
+ if (this.initialized) this.logger.warn("[moost] setReplaceRegistry() called after init() — already-bound controllers will not see these replacements. Register replacements before init().");
1757
2096
  this.replace = {
1758
2097
  ...this.replace,
1759
2098
  ...replace
@@ -1761,11 +2100,48 @@ function _define_property(obj, key, value) {
1761
2100
  return this;
1762
2101
  }
1763
2102
  /**
1764
- * Register controllers (similar to @ImportController decorator)
1765
- * @param controllers - list of target controllers (instances)
2103
+ * Register controllers with the app (similar to the `@ImportController` decorator).
2104
+ *
2105
+ * Accepted forms (mixable in one call):
2106
+ *
2107
+ * 1. **Class or instance** — `registerControllers(UsersController)`.
2108
+ * Mounted at `globalPrefix + '/' + own @Controller prefix`.
2109
+ *
2110
+ * 2. **Tuple `[prefix, controller]`** — `registerControllers(['api/users', UsersController])`.
2111
+ * **IMPORTANT: the string REPLACES the controller's own `@Controller(...)` prefix — it does
2112
+ * NOT prepend to it.** `['api', UsersController]` mounts a `@Controller('users')` class at
2113
+ * `/api`, not `/api/users`, so every tuple registration must repeat the full path.
2114
+ * To compose prefixes instead, use the object form below.
2115
+ *
2116
+ * 3. **Object group `{ prefix, controllers, mode? }`** —
2117
+ * `registerControllers({ prefix: 'api', controllers: [UsersController] })`.
2118
+ * Registers every entry of `controllers` under `prefix`:
2119
+ * - `mode: 'prepend'` (default) composes the prefixes:
2120
+ * `globalPrefix + '/' + prefix + '/' + own @Controller prefix`
2121
+ * (a `@Controller('users')` class mounts at `/api/users`);
2122
+ * - `mode: 'replace'` replaces each controller's own prefix with `prefix`
2123
+ * (same semantics as the tuple form).
2124
+ *
2125
+ * The object form is detected only for plain objects with a `controllers` array, so
2126
+ * controller classes, instances and tuples keep working unchanged. To mount the whole
2127
+ * app under one segment, prefer the `globalPrefix` option (see {@link TMoostOptions}).
2128
+ *
2129
+ * @param controllers - controllers to register: classes, instances,
2130
+ * `[prefix, controller]` tuples or `{ prefix, controllers, mode? }` groups
1766
2131
  * @returns
1767
2132
  */ registerControllers(...controllers) {
1768
- this.unregisteredControllers.push(...controllers);
2133
+ for (const entry of controllers) if (Array.isArray(entry) && typeof entry[0] === "string") this.unregisteredControllers.push({
2134
+ controller: entry[1],
2135
+ replaceOwnPrefix: entry[0]
2136
+ });
2137
+ else if (isControllersGroup(entry)) for (const controller of entry.controllers) this.unregisteredControllers.push(entry.mode === "replace" ? {
2138
+ controller,
2139
+ replaceOwnPrefix: entry.prefix
2140
+ } : {
2141
+ controller,
2142
+ prependPrefix: entry.prefix
2143
+ });
2144
+ else this.unregisteredControllers.push({ controller: entry });
1769
2145
  return this;
1770
2146
  }
1771
2147
  logMappedHandler(eventName, classConstructor, method, stroke, prefix) {
@@ -1774,7 +2150,9 @@ function _define_property(obj, key, value) {
1774
2150
  this.logger.info(`${prefix || ""}${c}${eventName} ${"\x1B[0m\x1B[2m\x1B[32m" + c}→ ${classConstructor.name}.${"\x1B[36m" + c}${method}()${coff}`);
1775
2151
  }
1776
2152
  constructor(options) {
1777
- super(), _define_property(this, "options", void 0), _define_property(this, "logger", void 0), _define_property(this, "pipes", void 0), _define_property(this, "interceptors", void 0), _define_property(this, "adapters", void 0), _define_property(this, "controllersOverview", void 0), _define_property(this, "handlerOverviewIndex", void 0), _define_property(this, "initHooks", void 0), _define_property(this, "provide", void 0), _define_property(this, "replace", void 0), _define_property(this, "unregisteredControllers", void 0), _define_property(this, "globalInterceptorHandler", void 0), this.options = options, this.pipes = Array.from(sharedPipes), this.interceptors = [], this.adapters = [], this.controllersOverview = [], this.initHooks = [], this.provide = (0, _prostojs_infact.createProvideRegistry)([_prostojs_infact.Infact, getMoostInfact], [_prostojs_mate.Mate, getMoostMate]), this.replace = {}, this.unregisteredControllers = [];
2153
+ super(), _define_property(this, "options", void 0), _define_property(this, "logger", void 0), _define_property(this, "pipes", void 0), _define_property(this, "interceptors", void 0), _define_property(this, "adapters", void 0), _define_property(this, "controllersOverview", void 0), _define_property(this, "handlerOverviewIndex", void 0), _define_property(this, "initHooks", void 0), _define_property(this, "provide", void 0), _define_property(this, "replace", void 0), _define_property(this, "unregisteredControllers", void 0), _define_property(this, "paramAuditFindings", void 0), _define_property(this, "paramAuditMode", void 0), _define_property(this, "inheritanceAuditMode", void 0), _define_property(this, "initialized", void 0), _define_property(this, "globalInterceptorHandler", void 0), this.options = options, this.pipes = Array.from(sharedPipes), this.interceptors = [], this.adapters = [], this.controllersOverview = [], this.initHooks = [], this.provide = (0, _prostojs_infact.createProvideRegistry)([_prostojs_infact.Infact, getMoostInfact], [_prostojs_mate.Mate, getMoostMate]), this.replace = {}, this.unregisteredControllers = [], this.paramAuditFindings = [], this.initialized = false;
2154
+ this.paramAuditMode = resolveParamAuditMode(options?.diagnostics?.paramTypes);
2155
+ this.inheritanceAuditMode = options?.diagnostics?.inheritance || "warn";
1778
2156
  this.logger = options?.logger || getDefaultLogger(`moost`);
1779
2157
  setDefaultLogger(this.logger);
1780
2158
  const mate = getMoostMate();
@@ -1880,6 +2258,7 @@ exports.Interceptor = Interceptor;
1880
2258
  exports.InterceptorHandler = InterceptorHandler;
1881
2259
  exports.Label = Label;
1882
2260
  exports.LoggerTopic = LoggerTopic;
2261
+ exports.MOOST_MODULE_IDENTITY_KEY = MOOST_MODULE_IDENTITY_KEY;
1883
2262
  exports.Moost = Moost;
1884
2263
  exports.MoostInit = MoostInit;
1885
2264
  exports.OnError = OnError;
@@ -2017,6 +2396,8 @@ exports.setControllerContext = setControllerContext;
2017
2396
  exports.setInfactLoggingOptions = setInfactLoggingOptions;
2018
2397
  exports.setInterceptResult = setInterceptResult;
2019
2398
  exports.setOvertake = setOvertake;
2399
+ exports.stampModuleIdentity = stampModuleIdentity;
2400
+ exports.stampOnce = stampOnce;
2020
2401
  exports.useControllerContext = useControllerContext;
2021
2402
  exports.useHandlerPaths = useHandlerPaths;
2022
2403
  exports.useInterceptResult = useInterceptResult;