@starci/eslint-canon-be 1.8.0 → 2.0.0

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.
Files changed (62) hide show
  1. package/CHANGELOG.md +95 -5
  2. package/README.md +41 -52
  3. package/async-discipline.mjs +2 -2
  4. package/authorization.mjs +38 -88
  5. package/builders.mjs +305 -0
  6. package/comments.mjs +19 -34
  7. package/config-owner.mjs +127 -125
  8. package/connections.mjs +254 -0
  9. package/cqrs.mjs +426 -135
  10. package/data-access.mjs +320 -391
  11. package/default-deny.mjs +14 -10
  12. package/e2e-flow.mjs +208 -180
  13. package/error-handling.mjs +254 -128
  14. package/idempotency.mjs +98 -67
  15. package/index.mjs +32 -9
  16. package/infra-owner.mjs +96 -0
  17. package/injection.mjs +602 -0
  18. package/input-bounds.mjs +166 -66
  19. package/lib/config.mjs +70 -33
  20. package/lib/db-writes.mjs +58 -0
  21. package/lib/declared.mjs +154 -0
  22. package/lib/hfs.mjs +61 -0
  23. package/lib/import-source.mjs +104 -0
  24. package/lib/persistence.mjs +160 -0
  25. package/lib/ports.mjs +138 -0
  26. package/lib/transport-slots.mjs +14 -0
  27. package/lib/types.mjs +133 -0
  28. package/lib/unit-spec.mjs +33 -0
  29. package/log-safety.mjs +94 -26
  30. package/module-layering.mjs +180 -297
  31. package/module-shape.mjs +296 -59
  32. package/naming.mjs +44 -12
  33. package/observability.mjs +57 -91
  34. package/operations.mjs +201 -0
  35. package/package.json +10 -7
  36. package/query-safety.mjs +189 -223
  37. package/resilience.mjs +126 -31
  38. package/runtime/engine/plain-object.mjs +5 -0
  39. package/runtime/engine/runtime-root.mjs +32 -0
  40. package/runtime/engine/yaml.mjs +161 -0
  41. package/runtime/knowledge/hfs/slots.yaml +1111 -0
  42. package/runtime/scripts/lib/git.mjs +53 -0
  43. package/runtime/scripts/lib/glob.mjs +23 -0
  44. package/runtime/scripts/lib/hfs-allows.mjs +57 -0
  45. package/runtime/scripts/lib/hfs-slots.mjs +729 -0
  46. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  47. package/runtime/scripts/lib/language.mjs +107 -0
  48. package/runtime/scripts/lib/path-key.mjs +17 -0
  49. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  50. package/schema-authority.mjs +273 -91
  51. package/size-budget.mjs +19 -61
  52. package/spec-no-skip.mjs +84 -0
  53. package/spec-quality.mjs +76 -35
  54. package/suppression.mjs +34 -16
  55. package/temporal.mjs +101 -36
  56. package/test-world.mjs +204 -0
  57. package/testing.mjs +99 -251
  58. package/transport.mjs +433 -142
  59. package/type-safety.mjs +50 -278
  60. package/unit-spec.mjs +452 -0
  61. package/user-copy.mjs +56 -37
  62. package/lib/slots.mjs +0 -83
package/injection.mjs ADDED
@@ -0,0 +1,602 @@
1
+ /**
2
+ * The rules that hold dependency injection (catalog R85 `BE_RAW_INJECT`, BE-CONVENTION 1.3).
3
+ *
4
+ * Every injected infrastructure dependency arrives through a zero-argument `Inject<Thing>()` decorator exported from
5
+ * its owner's `<name>.decorators.ts`, built with `injector<T>(token)` over a `unique symbol` token:
6
+ *
7
+ * - `injector-only`: `Inject(`, `InjectEntityManager(`, `InjectDataSource(`, `InjectQueue(`, `InjectRepository(` and
8
+ * any function whose name starts with `Inject` that a PACKAGE declares are called only in a `*.decorators.ts` at an
9
+ * owner root; a raw `@Inject(...)` decorator is a finding everywhere, and an injector decorates constructor
10
+ * parameters only (a property or method parameter is a finding).
11
+ * - `injector-shape`: in `*.decorators.ts` every `Inject<Thing>` is a zero-parameter function typed
12
+ * `TypedParameterDecorator<T>` whose body is `injector<T>(token)`, documented with JSDoc naming `T`; an exported
13
+ * token is `unique symbol = Symbol("<owner>.<thing>")`; `provide:` is a class or a `unique symbol`.
14
+ * - `injector-type-match`: the `T` of the injector on a constructor parameter is the parameter's annotated type.
15
+ * - `infra-needs-injector`: a constructor parameter typed by a platform/integrations declaration or by a package
16
+ * carries an `Inject<Thing>()` decorator; only domain services and types of the same owner are class-injected.
17
+ * Only a class the container builds is judged (a Nest class decorator or a decorated parameter); a plain class is not.
18
+ * - `no-module-ref`, `no-forward-ref`: no service locator, no cycle-hiding trick.
19
+ * - `no-string-token`: a token is a class or a `unique symbol`, never a string.
20
+ *
21
+ * Callees are identified by where the called function is DECLARED (the checker's resolved signature), so a renamed
22
+ * import, a namespace import and an alias all resolve to the same function. A lookalike (`InjectFoo` declared in the
23
+ * repository) is a custom injector and is judged by its shape, not by its name. Nothing here reads a variable name:
24
+ * `Inject*` names are the convention's fixed vocabulary, not a receiver guess.
25
+ */
26
+ import { posix } from "node:path"
27
+ import ts from "typescript"
28
+ import { staticText } from "./lib/ast.mjs"
29
+ import { hfsOf } from "./lib/hfs.mjs"
30
+ import { isImportedFrom } from "./lib/import-source.mjs"
31
+ import { normalizePath } from "./lib/path.mjs"
32
+ import { isPackageExport, packageOfExport, typed, typeOrigins } from "./lib/types.mjs"
33
+
34
+ /** The raw decorators of Nest and TypeORM whose call is the injector's job. */
35
+ const RAW_NAMES = new Set(["Inject", "InjectEntityManager", "InjectDataSource", "InjectQueue", "InjectRepository"])
36
+
37
+ /** The package a declaration file belongs to (the innermost `node_modules/<pkg>`), else null; the compiler's own lib files are not packages. */
38
+ const packageOfFile = (file) => {
39
+ if (/\/node_modules\/typescript\/lib\//.test(file)) return null
40
+ const match = /^.*\/node_modules\/((?:@[^/]+\/)?[^/]+)\//.exec(file)
41
+ return match ? match[1] : null
42
+ }
43
+
44
+ /** The name a declaration is known by: its own name, or the variable an arrow/function expression is assigned to. */
45
+ const declarationName = (declaration) => {
46
+ if (declaration.name && ts.isIdentifier(declaration.name)) return declaration.name.text
47
+ const parent = declaration.parent
48
+ if (parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)) return parent.name.text
49
+ return null
50
+ }
51
+
52
+ /** The variable a name resolves to from a node's scope. */
53
+ const variableOf = (context, node, name) => {
54
+ for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) {
55
+ const variable = scope.set.get(name)
56
+ if (variable) return variable
57
+ }
58
+ return null
59
+ }
60
+
61
+ /** The import a callee identifier or namespace member comes from, read from syntax when the checker cannot resolve it. */
62
+ const importedCallee = (context, callee) => {
63
+ const identifier = callee.type === "MemberExpression" && callee.object.type === "Identifier" ? callee.object : callee
64
+ if (identifier.type !== "Identifier") return { name: null, module: null, file: null, ambient: false }
65
+ const variable = variableOf(context, identifier, identifier.name)
66
+ const def = variable?.defs.find((entry) => entry.type === "ImportBinding")
67
+ if (!def) {
68
+ const local = (variable?.defs.length ?? 0) > 0
69
+ const name = callee.type === "MemberExpression" ? staticPropertyName(callee) : identifier.name
70
+ return { name, module: null, file: null, ambient: !local }
71
+ }
72
+ const source = String(def.parent.source.value)
73
+ const specifier = def.node
74
+ const name = specifier.type === "ImportSpecifier"
75
+ ? (specifier.imported.name ?? specifier.imported.value)
76
+ : callee.type === "MemberExpression" ? staticPropertyName(callee) : "default"
77
+ return { name, module: source.startsWith(".") ? null : source, file: null, ambient: false }
78
+ }
79
+
80
+ /** The property a member expression names (`common.Inject` -> `Inject`), else null. */
81
+ const staticPropertyName = (member) => {
82
+ if (member.computed) return staticText(member.property)
83
+ return member.property.type === "Identifier" ? member.property.name : null
84
+ }
85
+
86
+ /**
87
+ * Where the function a call invokes (or a decorator names) is declared.
88
+ *
89
+ * @param {object} context - The ESLint rule context.
90
+ * @param {object} node - A CallExpression, or an Identifier used as a bare decorator.
91
+ * @returns {{ name: string | null, file: string | null, module: string | null, ambient: boolean }} The declaration's name, file and package; `ambient` is true for a name nothing declares.
92
+ */
93
+ const calleeOf = (context, node) => {
94
+ const { checker, toTs } = typed(context)
95
+ const tsNode = toTs(node)
96
+ let declaration
97
+ if (tsNode && ts.isCallExpression(tsNode)) declaration = checker.getResolvedSignature(tsNode)?.declaration
98
+ else if (tsNode) {
99
+ let symbol = checker.getSymbolAtLocation(tsNode)
100
+ if (symbol && symbol.flags & ts.SymbolFlags.Alias) symbol = checker.getAliasedSymbol(symbol)
101
+ declaration = symbol?.getDeclarations?.()?.[0]
102
+ }
103
+ if (declaration?.getSourceFile()) {
104
+ const file = normalizePath(declaration.getSourceFile().fileName)
105
+ return { name: declarationName(declaration), file, module: packageOfFile(file), ambient: false }
106
+ }
107
+ return importedCallee(context, node.type === "CallExpression" ? node.callee : node)
108
+ }
109
+
110
+ /** The convention's injector vocabulary: `Inject` or `Inject<Thing>`; `Injectable` and other lower-case continuations are not injectors. */
111
+ const INJECT_NAME = /^Inject(?:[A-Z][A-Za-z0-9]*)?$/
112
+
113
+ /** True for a raw injection call: one of the five reserved names, or any `Inject*` function a package declares. */
114
+ const isRawInject = (callee) => {
115
+ if (callee.name === null) return false
116
+ if (callee.module !== null) return INJECT_NAME.test(callee.name)
117
+ return callee.ambient && RAW_NAMES.has(callee.name)
118
+ }
119
+
120
+ /** True for a file whose NAME role is an injector home. */
121
+ const isDecoratorsFile = (filename) => posix.basename(normalizePath(filename)).endsWith(".decorators.ts")
122
+
123
+ /** True when the file sits directly in an owner root (`src/modules/platform/clock/`), which is where its owner's injectors live. */
124
+ const atOwnerRoot = (hfs, filename) => {
125
+ const owner = hfs.ownerOf(filename)
126
+ return owner !== null && posix.dirname(hfs.relative(filename)) === owner
127
+ }
128
+
129
+ /** True for a file of the `platform/composition` owner, the home of `injector` and `TypedParameterDecorator`. */
130
+ const inComposition = (hfs, file) => {
131
+ if (!file || file.includes("/node_modules/") || hfs.tierOf(file) !== "platform") return false
132
+ const owner = hfs.ownerOf(file)
133
+ return owner !== null && posix.basename(owner) === "composition"
134
+ }
135
+
136
+ /** The decorators of a constructor parameter: on the parameter, its wrapper (`private readonly x`) or its inner identifier. */
137
+ const decoratorsOfParam = (param) => {
138
+ const inner = param.type === "TSParameterProperty" ? param.parameter : param
139
+ const target = inner.type === "AssignmentPattern" ? inner.left : inner
140
+ return { target, decorators: [...new Set([...(param.decorators ?? []), ...(inner.decorators ?? []), ...(target.decorators ?? [])])] }
141
+ }
142
+
143
+ /** The injected type `T` of a call typed `TypedParameterDecorator<T>`, else null. */
144
+ const injectedTypeOf = (context, call) => {
145
+ const { checker, toTs } = typed(context)
146
+ const tsCall = toTs(call)
147
+ if (!tsCall) return null
148
+ const type = checker.getTypeAtLocation(tsCall)
149
+ if (type.aliasSymbol?.name === "TypedParameterDecorator" && type.aliasTypeArguments?.length === 1) return type.aliasTypeArguments[0]
150
+ return null
151
+ }
152
+
153
+ /** The parameters of every constructor a class declares. */
154
+ const constructorParams = (node) => (node.kind === "constructor" && node.value?.params) || []
155
+
156
+ /** True when the function around a decorated parameter is a class constructor. */
157
+ const isConstructorParam = (decorator) => {
158
+ let node = decorator.parent
159
+ while (node && !/^(?:FunctionExpression|ArrowFunctionExpression|FunctionDeclaration|ClassDeclaration|ClassExpression|Program)$/.test(node.type)) node = node.parent
160
+ return node?.type === "FunctionExpression" && node.parent?.type === "MethodDefinition" && node.parent.kind === "constructor"
161
+ }
162
+
163
+ /** True when a decorator sits on a class member or a parameter (not on a class). */
164
+ const memberKind = (decorator) => {
165
+ const owner = decorator.parent
166
+ if (!owner) return "class"
167
+ if (owner.type === "PropertyDefinition" || owner.type === "AccessorProperty") return "property"
168
+ if (owner.type === "MethodDefinition") return "method"
169
+ if (owner.type === "ClassDeclaration" || owner.type === "ClassExpression") return "class"
170
+ return "parameter"
171
+ }
172
+
173
+ // -- injector-only ---------------------------------------------------------------------------------
174
+
175
+ /** Raw injection lives only in `<owner>.decorators.ts`; injectors decorate constructor parameters. */
176
+ export const injectorOnly = {
177
+ meta: {
178
+ type: "problem",
179
+ docs: { description: "`Inject(...)` and third-party `Inject*` are called only in `<owner>.decorators.ts` at an owner root; injectors decorate constructor parameters." },
180
+ schema: [],
181
+ messages: {
182
+ rawCall: "`{{name}}(...)` is called outside a `<owner>.decorators.ts` at an owner root. Raw injection puts the token choice at every call site; write `Inject<Thing>()` once in the owner's decorators file (`injector<T>(token)`) and decorate the parameter with it.",
183
+ rawDecorator: "`@{{name}}(...)` is a raw injection decorator. Decorate the constructor parameter with the owner's `Inject<Thing>()` instead; the raw call belongs inside that injector in `<owner>.decorators.ts`.",
184
+ property: "`@{{name}}()` decorates a class property. Injectors decorate constructor parameters only; move the dependency to a constructor parameter.",
185
+ notConstructor: "`@{{name}}()` decorates a parameter that is not a constructor parameter. Injectors decorate constructor parameters only.",
186
+ },
187
+ },
188
+ create(context) {
189
+ const hfs = hfsOf(context)
190
+ const filename = context.filename || context.getFilename()
191
+ const home = isDecoratorsFile(filename) && atOwnerRoot(hfs, filename)
192
+ return {
193
+ Decorator(node) {
194
+ const expression = node.expression
195
+ if (expression.type !== "CallExpression" && expression.type !== "Identifier") return
196
+ const callee = calleeOf(context, expression)
197
+ const written = callee.name ?? ""
198
+ if (isRawInject(callee)) {
199
+ context.report({ node, messageId: "rawDecorator", data: { name: written } })
200
+ return
201
+ }
202
+ if (!INJECT_NAME.test(written)) return
203
+ const kind = memberKind(node)
204
+ if (kind === "property" || kind === "method") context.report({ node, messageId: "property", data: { name: written } })
205
+ else if (kind === "parameter" && !isConstructorParam(node)) context.report({ node, messageId: "notConstructor", data: { name: written } })
206
+ },
207
+ CallExpression(node) {
208
+ if (home) return
209
+ if (node.parent?.type === "Decorator" && node.parent.expression === node) return
210
+ const callee = calleeOf(context, node)
211
+ if (isRawInject(callee)) context.report({ node, messageId: "rawCall", data: { name: callee.name } })
212
+ },
213
+ }
214
+ },
215
+ }
216
+
217
+ // -- injector-shape --------------------------------------------------------------------------------
218
+
219
+ /** The nearest JSDoc block before a statement, else null. */
220
+ const jsdocBefore = (context, statement) => {
221
+ const comments = context.sourceCode.getCommentsBefore(statement)
222
+ const last = comments[comments.length - 1]
223
+ return last && last.type === "Block" && last.value.startsWith("*") && last.value.replace(/[*\s]/g, "") !== "" ? last.value : null
224
+ }
225
+
226
+ /** The single expression a function returns (arrow expression body, or a block that is one `return`), else null. */
227
+ const returnedExpression = (fn) => {
228
+ if (fn.body.type !== "BlockStatement") return fn.body
229
+ const [only] = fn.body.body
230
+ return fn.body.body.length === 1 && only.type === "ReturnStatement" ? only.argument : null
231
+ }
232
+
233
+ /** The type arguments of a node, in either parser spelling. */
234
+ const typeArgsOf = (node) => (node.typeArguments ?? node.typeParameters)?.params ?? []
235
+
236
+ /** The exported function-like bindings of a statement: `[{ name, fn, id }]`. */
237
+ const exportedFunctions = (statement) => {
238
+ const declaration = statement.declaration
239
+ if (!declaration) return []
240
+ if (declaration.type === "FunctionDeclaration" && declaration.id) return [{ name: declaration.id.name, fn: declaration, id: declaration.id }]
241
+ if (declaration.type !== "VariableDeclaration") return []
242
+ return declaration.declarations
243
+ .filter((entry) => entry.id.type === "Identifier" && entry.init && /^(?:ArrowFunctionExpression|FunctionExpression)$/.test(entry.init.type))
244
+ .map((entry) => ({ name: entry.id.name, fn: entry.init, id: entry.id }))
245
+ }
246
+
247
+ /** True when an initializer is a call whose callee is named `injector` (whatever it resolves to is checked later). */
248
+ const callsInjector = (context, fn) => {
249
+ const expression = returnedExpression(fn)
250
+ return expression?.type === "CallExpression" && expression.callee.type === "Identifier" && expression.callee.name === "injector"
251
+ }
252
+
253
+ /** True for an `Inject<Thing>`-looking export (the convention's fixed vocabulary), excluding the builder itself. */
254
+ const looksLikeInjector = (context, entry) =>
255
+ entry.name !== "injector" && (/^[Ii]nject/.test(entry.name) || entry.fn.returnType?.typeAnnotation?.typeName?.name === "TypedParameterDecorator" || callsInjector(context, entry.fn))
256
+
257
+ /** True for a value that is a class or a `unique symbol`. */
258
+ const isTokenType = (type) => {
259
+ if (type.isUnion()) return type.types.every(isTokenType)
260
+ if (type.flags & ts.TypeFlags.UniqueESSymbol) return true
261
+ if (type.getConstructSignatures().length > 0) return true
262
+ return Boolean(type.symbol && type.symbol.flags & ts.SymbolFlags.Class)
263
+ }
264
+
265
+ /** True for a string type (a string literal, `string`, or a union of them). */
266
+ const isStringType = (type) => (type.isUnion() ? type.types.every(isStringType) : (type.flags & ts.TypeFlags.StringLike) !== 0)
267
+
268
+ /** The value of a `provide:` property. */
269
+ const provideValue = (node) => {
270
+ if (node.type !== "Property" || node.parent?.type !== "ObjectExpression" || (node.computed && node.key.type !== "Literal")) return null
271
+ const name = node.key.type === "Identifier" ? node.key.name : node.key.value
272
+ return name === "provide" ? node.value : null
273
+ }
274
+
275
+ /** The shape of an injector, its token and the `provide:` tokens of a module. */
276
+ export const injectorShape = {
277
+ meta: {
278
+ type: "problem",
279
+ docs: { description: "An injector is `(): TypedParameterDecorator<T> => injector<T>(token)` with JSDoc, over a `unique symbol` token." },
280
+ schema: [],
281
+ messages: {
282
+ name: "`{{name}}` must be named `Inject` followed by a PascalCase thing (`InjectClock`, `InjectPrimaryEntityManager`).",
283
+ params: "`{{name}}` takes a parameter. An injector is zero-argument; a keyed variant is a separate injector (`InjectDigitalOceanS3`, `InjectMinioS3`, never `InjectS3(key)`).",
284
+ returnType: "`{{name}}` must declare its return type `TypedParameterDecorator<T>` from `platform/composition`, so the lint rule can compare `T` with the parameter annotation.",
285
+ body: "`{{name}}` must be exactly `injector<T>(token)` with the same `T` as its return type, `injector` imported from `platform/composition`.",
286
+ jsdoc: "`{{name}}` needs a JSDoc comment that names the injected type (`Injects the Clock port. Parameter type: Clock.`).",
287
+ tokenType: "The token `{{name}}` must be declared `export const {{name}}: unique symbol = Symbol(\"<owner>.<thing>\")`.",
288
+ tokenName: "The token `{{name}}` must be described `<owner>.<thing>` in lower-case dotted words that include its owner `{{owner}}` (`Symbol(\"platform.clock\")`).",
289
+ provide: "`provide:` must be a class or a `unique symbol` token, not a value of type `{{type}}`.",
290
+ },
291
+ },
292
+ create(context) {
293
+ const hfs = hfsOf(context)
294
+ const filename = context.filename || context.getFilename()
295
+ const listeners = {
296
+ Property(node) {
297
+ const value = provideValue(node)
298
+ if (!value) return
299
+ const { checker, toTs } = typed(context)
300
+ const tsValue = toTs(value)
301
+ if (!tsValue) return
302
+ const type = checker.getTypeAtLocation(tsValue)
303
+ if (isStringType(type) || isTokenType(type)) return
304
+ context.report({ node: value, messageId: "provide", data: { type: checker.typeToString(type) } })
305
+ },
306
+ }
307
+ if (!isDecoratorsFile(filename)) return listeners
308
+ const ownerName = posix.basename(hfs.ownerOf(filename) ?? "")
309
+ return {
310
+ ...listeners,
311
+ ExportNamedDeclaration(statement) {
312
+ for (const entry of exportedFunctions(statement)) {
313
+ if (!looksLikeInjector(context, entry)) continue
314
+ checkInjector(context, hfs, statement, entry)
315
+ }
316
+ if (statement.declaration?.type !== "VariableDeclaration") return
317
+ for (const entry of statement.declaration.declarations) {
318
+ const init = entry.init
319
+ const symbolCall = init?.type === "CallExpression" && (init.callee.type === "Identifier" ? init.callee.name === "Symbol" : init.callee.type === "MemberExpression" && init.callee.object.type === "Identifier" && init.callee.object.name === "Symbol")
320
+ if (!symbolCall || entry.id.type !== "Identifier") continue
321
+ const annotation = entry.id.typeAnnotation?.typeAnnotation
322
+ const unique = annotation?.type === "TSTypeOperator" && annotation.operator === "unique" && annotation.typeAnnotation?.type === "TSSymbolKeyword"
323
+ const description = init.callee.type === "Identifier" && init.arguments.length === 1 ? staticText(init.arguments[0]) : null
324
+ if (!unique) context.report({ node: entry.id, messageId: "tokenType", data: { name: entry.id.name } })
325
+ else if (description === null || !/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/.test(description) || !description.split(".").includes(ownerName)) {
326
+ context.report({ node: entry.id, messageId: "tokenName", data: { name: entry.id.name, owner: ownerName } })
327
+ }
328
+ }
329
+ },
330
+ }
331
+ },
332
+ }
333
+
334
+ /** Reports every way one exported injector departs from the canonical shape. */
335
+ const checkInjector = (context, hfs, statement, { name, fn, id }) => {
336
+ const data = { name }
337
+ if (!/^Inject[A-Z][A-Za-z0-9]*$/.test(name)) context.report({ node: id, messageId: "name", data })
338
+ if (fn.params.length > 0) context.report({ node: fn.params[0], messageId: "params", data })
339
+ const annotation = fn.returnType?.typeAnnotation
340
+ const wellTyped = annotation?.type === "TSTypeReference" && annotation.typeName.type === "Identifier" && annotation.typeName.name === "TypedParameterDecorator" && typeArgsOf(annotation).length === 1
341
+ const fromComposition = wellTyped && typeOrigins(context, annotation).some((origin) => origin.name === "TypedParameterDecorator" && inComposition(hfs, origin.file))
342
+ if (!fromComposition) {
343
+ context.report({ node: fn.returnType ?? id, messageId: "returnType", data })
344
+ return
345
+ }
346
+ const injected = typeArgsOf(annotation)[0]
347
+ const injectedText = context.sourceCode.getText(injected)
348
+ const call = returnedExpression(fn)
349
+ const built = call?.type === "CallExpression" && call.callee.type === "Identifier" && call.callee.name === "injector"
350
+ && call.arguments.length === 1 && typeArgsOf(call).length === 1 && context.sourceCode.getText(typeArgsOf(call)[0]) === injectedText
351
+ && inComposition(hfs, calleeOf(context, call).file)
352
+ if (!built) context.report({ node: call ?? fn, messageId: "body", data })
353
+ const doc = jsdocBefore(context, statement)
354
+ const mention = injectedText.match(/[A-Za-z_$][\w$]*/)?.[0] ?? injectedText
355
+ if (doc === null || !new RegExp(`\\b${mention.replace(/\$/g, "\\$")}\\b`).test(doc)) context.report({ node: id, messageId: "jsdoc", data })
356
+ }
357
+
358
+ // -- injector-type-match ---------------------------------------------------------------------------
359
+
360
+ /** `T` of the injector equals the parameter annotation. */
361
+ export const injectorTypeMatch = {
362
+ meta: {
363
+ type: "problem",
364
+ docs: { description: "The `T` of an injector's `TypedParameterDecorator<T>` is the type the parameter is annotated with." },
365
+ schema: [],
366
+ messages: {
367
+ mismatch: "`@{{injector}}()` injects `{{injected}}` but the parameter is declared `{{declared}}`. Annotate the parameter with `{{injected}}`, or use the injector of `{{declared}}`.",
368
+ unresolved: "`@{{injector}}()` injects a type the checker cannot resolve, so it cannot be compared with `{{declared}}`. Fix the injector's `TypedParameterDecorator<T>` so `T` is a concrete type.",
369
+ unannotated: "A parameter decorated `@{{injector}}()` has no type annotation. Annotate it with `{{injected}}`.",
370
+ },
371
+ },
372
+ create(context) {
373
+ return {
374
+ MethodDefinition(node) {
375
+ for (const param of constructorParams(node)) {
376
+ const { target, decorators } = decoratorsOfParam(param)
377
+ for (const decorator of decorators) {
378
+ if (decorator.expression.type !== "CallExpression") continue
379
+ const injected = injectedTypeOf(context, decorator.expression)
380
+ if (!injected) continue
381
+ check(context, decorator, target, injected)
382
+ }
383
+ }
384
+ },
385
+ }
386
+ },
387
+ }
388
+
389
+ /** Compares one injector's `T` with the annotation of the parameter it decorates. */
390
+ const check = (context, decorator, target, injected) => {
391
+ const { checker, toTs } = typed(context)
392
+ const callee = decorator.expression.callee
393
+ const injector = callee.type === "Identifier" ? callee.name : (staticPropertyName(callee) ?? "Inject")
394
+ const annotation = target.typeAnnotation?.typeAnnotation
395
+ const injectedName = checker.typeToString(injected)
396
+ if (!annotation) {
397
+ context.report({ node: target, messageId: "unannotated", data: { injector, injected: injectedName } })
398
+ return
399
+ }
400
+ const tsAnnotation = toTs(annotation)
401
+ if (!tsAnnotation) return
402
+ const declared = checker.getNonNullableType(checker.getTypeFromTypeNode(tsAnnotation))
403
+ const declaredName = checker.typeToString(declared)
404
+ if (injected.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) {
405
+ context.report({ node: decorator, messageId: "unresolved", data: { injector, declared: declaredName } })
406
+ return
407
+ }
408
+ if (injected === declared) return
409
+ if (checker.isTypeAssignableTo(injected, declared) && checker.isTypeAssignableTo(declared, injected)) return
410
+ context.report({ node: annotation, messageId: "mismatch", data: { injector, injected: injectedName, declared: declaredName } })
411
+ }
412
+
413
+ // -- infra-needs-injector --------------------------------------------------------------------------
414
+
415
+ /** True when the class of a constructor can be built by the Nest container: a Nest class decorator, or any decorated constructor parameter. */
416
+ const isContainerBuilt = (context, constructor) => {
417
+ const declaration = constructor.parent?.parent
418
+ if (declaration?.type !== "ClassDeclaration" && declaration?.type !== "ClassExpression") return false
419
+ if ((declaration.decorators ?? []).some((decorator) => packageOfExport(context, decorator.expression)?.startsWith("@nestjs/"))) return true
420
+ return constructorParams(constructor).some((param) => decoratorsOfParam(param).decorators.length > 0)
421
+ }
422
+
423
+ /** A constructor parameter of an infrastructure type carries an injector. */
424
+ export const infraNeedsInjector = {
425
+ meta: {
426
+ type: "problem",
427
+ docs: { description: "A constructor parameter typed by a platform/integrations declaration or a package carries an `Inject<Thing>()` decorator." },
428
+ schema: [],
429
+ messages: {
430
+ missing: "`{{type}}` is infrastructure ({{where}}) and is injected here without an injector. Decorate the parameter with the owner's `Inject<Thing>()`; only domain services and types of this owner are injected by class.",
431
+ },
432
+ },
433
+ create(context) {
434
+ const hfs = hfsOf(context)
435
+ const filename = context.filename || context.getFilename()
436
+ const own = hfs.ownerOf(filename)
437
+ return {
438
+ MethodDefinition(node) {
439
+ // Only a class the container can construct is judged: it carries a Nest class decorator (`@Injectable()`, `@Controller()`,
440
+ // `@Resolver()`, `@Catch()`, `@CommandHandler(...)`), or a parameter decorator that makes TypeScript emit its parameter
441
+ // metadata. A plain class (a CQRS message, a factory-built adapter, a test fake) is never DI-constructed.
442
+ if (!isContainerBuilt(context, node)) return
443
+ for (const param of constructorParams(node)) {
444
+ const { target, decorators } = decoratorsOfParam(param)
445
+ const annotation = target.typeAnnotation?.typeAnnotation
446
+ if (!annotation) continue
447
+ const origins = typeOrigins(context, annotation)
448
+ // A language global (`ReadonlyArray`, `Array`) is declared in the TypeScript lib and may be augmented by a package
449
+ // (`@types/node`): the augmentation does not make it infrastructure.
450
+ const isLanguageGlobal = (origin) => origins.some((other) => other.name === origin.name && /\/node_modules\/typescript\/lib\//.test(other.file))
451
+ const infra = origins.find((origin) => {
452
+ if (isLanguageGlobal(origin)) return false
453
+ if (origin.module !== null) return true
454
+ if (own !== null && hfs.ownerOf(origin.file) === own) return false
455
+ const tier = hfs.tierOf(origin.file)
456
+ return tier === "platform" || tier === "integrations"
457
+ })
458
+ if (!infra) continue
459
+ const carries = decorators.some((decorator) => {
460
+ const expression = decorator.expression
461
+ if (expression.type !== "CallExpression") return false
462
+ return INJECT_NAME.test(calleeOf(context, expression).name ?? "") || injectedTypeOf(context, expression) !== null
463
+ })
464
+ if (carries) continue
465
+ const where = infra.module !== null ? `package ${infra.module}` : `${hfs.tierOf(infra.file)} ${posix.basename(hfs.ownerOf(infra.file) ?? "")}`
466
+ context.report({ node: target, messageId: "missing", data: { type: infra.name || context.sourceCode.getText(annotation), where } })
467
+ }
468
+ },
469
+ }
470
+ },
471
+ }
472
+
473
+ // -- no-module-ref ---------------------------------------------------------------------------------
474
+
475
+ const MODULE_REF_CALLS = new Set(["get", "resolve", "create"])
476
+
477
+ /** No service locator: `ModuleRef` is not imported, typed or called. */
478
+ export const noModuleRef = {
479
+ meta: {
480
+ type: "problem",
481
+ docs: { description: "`ModuleRef` (a service locator) is not used in product code or specs." },
482
+ schema: [],
483
+ messages: {
484
+ use: "`ModuleRef` is a service locator that bypasses every injection rule. Inject the dependency through its `Inject<Thing>()` in the constructor.",
485
+ },
486
+ },
487
+ create(context) {
488
+ const isModuleRef = (node) => typeOrigins(context, node).some((origin) => origin.name === "ModuleRef" && origin.module === "@nestjs/core")
489
+ return {
490
+ ImportDeclaration(node) {
491
+ if (node.source.value !== "@nestjs/core") return
492
+ for (const specifier of node.specifiers) {
493
+ if (specifier.type === "ImportSpecifier" && (specifier.imported.name ?? specifier.imported.value) === "ModuleRef") context.report({ node: specifier, messageId: "use" })
494
+ }
495
+ },
496
+ ExportNamedDeclaration(node) {
497
+ if (node.source?.value !== "@nestjs/core") return
498
+ for (const specifier of node.specifiers) {
499
+ if ((specifier.local.name ?? specifier.local.value) === "ModuleRef") context.report({ node: specifier, messageId: "use" })
500
+ }
501
+ },
502
+ TSTypeReference(node) {
503
+ const name = node.typeName.type === "Identifier" ? node.typeName.name : node.typeName.right?.name
504
+ if (name === "ModuleRef" && isModuleRef(node)) context.report({ node, messageId: "use" })
505
+ },
506
+ CallExpression(node) {
507
+ const callee = node.callee
508
+ if (callee.type !== "MemberExpression" || !MODULE_REF_CALLS.has(staticPropertyName(callee) ?? "")) return
509
+ if (isModuleRef(callee.object)) context.report({ node: callee, messageId: "use" })
510
+ },
511
+ }
512
+ },
513
+ }
514
+
515
+ // -- no-forward-ref --------------------------------------------------------------------------------
516
+
517
+ /** No `forwardRef(`: a cycle is a layering finding, not a DI trick. */
518
+ export const noForwardRef = {
519
+ meta: {
520
+ type: "problem",
521
+ docs: { description: "`forwardRef(...)` is not called." },
522
+ schema: [],
523
+ messages: {
524
+ cycle: "`forwardRef(...)` hides an import or provider cycle. Break the cycle (move the shared part to a lower owner); it is a layering finding, not an injection detail.",
525
+ },
526
+ },
527
+ create(context) {
528
+ return {
529
+ CallExpression(node) {
530
+ const callee = calleeOf(context, node)
531
+ if (callee.name === "forwardRef" && (callee.module !== null || callee.ambient)) context.report({ node, messageId: "cycle" })
532
+ },
533
+ }
534
+ },
535
+ }
536
+
537
+ // -- no-string-token -------------------------------------------------------------------------------
538
+
539
+ const TOKEN_GETTERS = new Set(["getEntityManagerToken", "getDataSourceToken"])
540
+
541
+ /** A token is a class or a `unique symbol`, never a string. */
542
+ export const noStringToken = {
543
+ meta: {
544
+ type: "problem",
545
+ docs: { description: "An injection token is never a string or template literal." },
546
+ schema: [],
547
+ messages: {
548
+ string: "A string is used as an injection token. Tokens are a class or `export const THING: unique symbol = Symbol(\"<owner>.<thing>\")` in the owner's `<owner>.decorators.ts`; strings collide silently.",
549
+ connection: "`{{name}}(\"...\")` takes a string literal. Name the connection once as a constant in `<conn>.connection.ts` and pass that constant.",
550
+ },
551
+ },
552
+ create(context) {
553
+ const filename = context.filename || context.getFilename()
554
+ const isString = (node) => {
555
+ if (isPackageExport(context, node, "@nestjs/core")) return false
556
+ if (node.type === "CallExpression" && TOKEN_GETTERS.has(calleeOf(context, node).name ?? "")) return false
557
+ if (staticText(node) !== null) return true
558
+ if (node.type === "TemplateLiteral") return true
559
+ const { checker, toTs } = typed(context)
560
+ const tsNode = toTs(node)
561
+ return tsNode ? isStringType(checker.getTypeAtLocation(tsNode)) : false
562
+ }
563
+ return {
564
+ Property(node) {
565
+ const value = provideValue(node)
566
+ if (value && isString(value)) context.report({ node: value, messageId: "string" })
567
+ },
568
+ CallExpression(node) {
569
+ const [first] = node.arguments
570
+ if (!first) return
571
+ const callee = calleeOf(context, node)
572
+ if (isRawInject(callee) || callee.name === "injector") {
573
+ if (isString(first)) context.report({ node: first, messageId: "string" })
574
+ } else if (TOKEN_GETTERS.has(callee.name ?? "") && callee.module !== null && !posix.basename(normalizePath(filename)).endsWith(".connection.ts")) {
575
+ if (staticText(first) !== null || first.type === "TemplateLiteral") context.report({ node: first, messageId: "connection", data: { name: callee.name } })
576
+ }
577
+ },
578
+ }
579
+ },
580
+ }
581
+
582
+ /** The rules this law contributes to the plugin. */
583
+ export const rules = {
584
+ "injector-only": injectorOnly,
585
+ "injector-shape": injectorShape,
586
+ "injector-type-match": injectorTypeMatch,
587
+ "infra-needs-injector": infraNeedsInjector,
588
+ "no-module-ref": noModuleRef,
589
+ "no-forward-ref": noForwardRef,
590
+ "no-string-token": noStringToken,
591
+ }
592
+
593
+ /** All start at error: there is one way to inject and no baseline. */
594
+ export const recommended = {
595
+ "starci-be/injector-only": "error",
596
+ "starci-be/injector-shape": "error",
597
+ "starci-be/injector-type-match": "error",
598
+ "starci-be/infra-needs-injector": "error",
599
+ "starci-be/no-module-ref": "error",
600
+ "starci-be/no-forward-ref": "error",
601
+ "starci-be/no-string-token": "error",
602
+ }