moost 0.6.30 → 0.6.32

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