@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.
- package/CHANGELOG.md +95 -5
- package/README.md +41 -52
- package/async-discipline.mjs +2 -2
- package/authorization.mjs +38 -88
- package/builders.mjs +305 -0
- package/comments.mjs +19 -34
- package/config-owner.mjs +127 -125
- package/connections.mjs +254 -0
- package/cqrs.mjs +426 -135
- package/data-access.mjs +320 -391
- package/default-deny.mjs +14 -10
- package/e2e-flow.mjs +208 -180
- package/error-handling.mjs +254 -128
- package/idempotency.mjs +98 -67
- package/index.mjs +32 -9
- package/infra-owner.mjs +96 -0
- package/injection.mjs +602 -0
- package/input-bounds.mjs +166 -66
- package/lib/config.mjs +70 -33
- package/lib/db-writes.mjs +58 -0
- package/lib/declared.mjs +154 -0
- package/lib/hfs.mjs +61 -0
- package/lib/import-source.mjs +104 -0
- package/lib/persistence.mjs +160 -0
- package/lib/ports.mjs +138 -0
- package/lib/transport-slots.mjs +14 -0
- package/lib/types.mjs +133 -0
- package/lib/unit-spec.mjs +33 -0
- package/log-safety.mjs +94 -26
- package/module-layering.mjs +180 -297
- package/module-shape.mjs +296 -59
- package/naming.mjs +44 -12
- package/observability.mjs +57 -91
- package/operations.mjs +201 -0
- package/package.json +10 -7
- package/query-safety.mjs +189 -223
- package/resilience.mjs +126 -31
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/engine/runtime-root.mjs +32 -0
- package/runtime/engine/yaml.mjs +161 -0
- package/runtime/knowledge/hfs/slots.yaml +1111 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/glob.mjs +23 -0
- package/runtime/scripts/lib/hfs-allows.mjs +57 -0
- package/runtime/scripts/lib/hfs-slots.mjs +729 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/path-key.mjs +17 -0
- package/runtime/scripts/lib/recorded-lines.mjs +27 -0
- package/schema-authority.mjs +273 -91
- package/size-budget.mjs +19 -61
- package/spec-no-skip.mjs +84 -0
- package/spec-quality.mjs +76 -35
- package/suppression.mjs +34 -16
- package/temporal.mjs +101 -36
- package/test-world.mjs +204 -0
- package/testing.mjs +99 -251
- package/transport.mjs +433 -142
- package/type-safety.mjs +50 -278
- package/unit-spec.mjs +452 -0
- package/user-copy.mjs +56 -37
- 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
|
+
}
|