@starci/eslint-canon-be 2.1.2 → 3.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 +15 -0
- package/README.md +8 -7
- package/comments.mjs +104 -1
- package/config-owner.mjs +113 -13
- package/connections.mjs +1 -12
- package/cqrs.mjs +68 -0
- package/data-access.mjs +100 -4
- package/error-handling.mjs +107 -1
- package/index.mjs +2 -0
- package/infra-owner.mjs +39 -0
- package/input-bounds.mjs +64 -2
- package/lib/config.mjs +1 -1
- package/lib/hfs.mjs +7 -6
- package/lib/persistence.mjs +29 -0
- package/log-safety.mjs +89 -2
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +28 -9
- package/runtime/knowledge/hfs/slots.yaml +193 -128
- package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +22 -0
- package/runtime/scripts/checks/architecture/config.mjs +31 -11
- package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
- package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
- package/runtime/scripts/checks/architecture/hfs.mjs +104 -66
- package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
- package/runtime/scripts/checks/architecture/registration.mjs +1 -1
- package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
- package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
- package/runtime/scripts/checks/architecture/typescript.mjs +45 -20
- package/runtime/scripts/checks/typescript-programs.mjs +2 -2
- package/runtime/scripts/lib/hfs-path-findings.mjs +13 -2
- package/runtime/scripts/lib/hfs-slots.mjs +244 -61
- package/runtime/scripts/lib/hfs-view.mjs +9 -7
- package/runtime/scripts/lib/language.mjs +11 -1
- package/service-deps.mjs +249 -0
- package/runtime/packages/hfs/templates/be/package-scripts/package.json +0 -19
- package/runtime/packages/hfs/templates/fe/package-scripts/package.json +0 -13
package/service-deps.mjs
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold what a provider's constructor may receive and what production source may contain
|
|
3
|
+
* (catalog R83 `BE_UNNAMED_DATA_ACCESS`, R85 `BE_RAW_INJECT`, R89 `BE_SOURCE_FORM`).
|
|
4
|
+
*
|
|
5
|
+
* A service is unit-tested with `Test.createTestingModule` and a token double for every constructor dependency. That
|
|
6
|
+
* only works when every dependency is a NAMED token and no dependency is a hidden door to the database or a fake:
|
|
7
|
+
*
|
|
8
|
+
* - `provider-param-token` (R85): a constructor parameter of a Nest provider (a class carrying a `@nestjs/*` class
|
|
9
|
+
* decorator) is typed by a CLASS (the token Nest resolves), or carries an `Inject<Thing>()` decorator bound to a
|
|
10
|
+
* named token. A primitive, a union, a `Map`, a function type, an interface or a type alias has no runtime
|
|
11
|
+
* token: it is either a broken provider or a raw string/number injection.
|
|
12
|
+
* - `no-repository-class` (R83): a class that receives or returns an `EntityManager`, a typeorm `Repository`, a
|
|
13
|
+
* `DataSource` or a `QueryRunner` in any member lives only where the manager may be held (an application handler,
|
|
14
|
+
* a domain service, a platform persistence capability), in the migrate app, in the test world or in a migration.
|
|
15
|
+
* A per-entity persistence class anywhere else is a repository under another name.
|
|
16
|
+
* - `no-test-double-in-source` (R89): a class, function or constant of production source is not named as a test
|
|
17
|
+
* double (`Mock`, `Fake`, `Stub` as a PascalCase or camelCase word). A double lives in a spec or `src/tests`;
|
|
18
|
+
* a production offline driver is an options-driven adapter of the port, named for what it does.
|
|
19
|
+
*
|
|
20
|
+
* Every question about a type asks where the type is DECLARED (`typeOrigins`), every question about a path asks the HFS
|
|
21
|
+
* slot view. A name is read only for the fixed vocabulary of the double markers, as whole words.
|
|
22
|
+
*/
|
|
23
|
+
import ts from "typescript"
|
|
24
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
25
|
+
import { isSpecFile, normalizePath } from "./lib/path.mjs"
|
|
26
|
+
import { implementsMigration, inMigrateApp, inTestBootstrap, isMigrationFile, mayHoldEntityManager } from "./lib/persistence.mjs"
|
|
27
|
+
import { typed, typeOrigins } from "./lib/types.mjs"
|
|
28
|
+
|
|
29
|
+
// -- provider-param-token --------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/** The convention's injector vocabulary: `Inject` or `Inject<Thing>`. */
|
|
32
|
+
const INJECT_NAME = /^Inject(?:[A-Z][A-Za-z0-9]*)?$/
|
|
33
|
+
|
|
34
|
+
/** The decorators of a constructor parameter: on the parameter, its wrapper (`private readonly x`) or its inner identifier. */
|
|
35
|
+
const paramParts = (param) => {
|
|
36
|
+
const inner = param.type === "TSParameterProperty" ? param.parameter : param
|
|
37
|
+
const target = inner.type === "AssignmentPattern" ? inner.left : inner
|
|
38
|
+
return { target, decorators: [...new Set([...(param.decorators ?? []), ...(inner.decorators ?? []), ...(target.decorators ?? [])])] }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** True when a decorator expression is a call of an `Inject*` function, or of anything typed `TypedParameterDecorator<T>`. */
|
|
42
|
+
const isInjector = (context, decorator) => {
|
|
43
|
+
const expression = decorator.expression
|
|
44
|
+
const callee = expression.type === "CallExpression" ? expression.callee : expression
|
|
45
|
+
const name = callee.type === "Identifier" ? callee.name : callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" ? callee.property.name : ""
|
|
46
|
+
if (INJECT_NAME.test(name)) return true
|
|
47
|
+
if (expression.type !== "CallExpression") return false
|
|
48
|
+
const { checker, toTs } = typed(context)
|
|
49
|
+
const tsCall = toTs(expression)
|
|
50
|
+
return Boolean(tsCall && checker.getTypeAtLocation(tsCall).aliasSymbol?.name === "TypedParameterDecorator")
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True when a class is a Nest provider-like: it carries a class decorator declared by a `@nestjs/*` package. */
|
|
54
|
+
const isNestClass = (context, node) =>
|
|
55
|
+
(node.decorators ?? []).some((decorator) => {
|
|
56
|
+
const callee = decorator.expression.type === "CallExpression" ? decorator.expression.callee : decorator.expression
|
|
57
|
+
return typeOrigins(context, callee).some((origin) => origin.module !== null && origin.module.startsWith("@nestjs/"))
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/** True when a written type is a plain reference to one class: the token Nest resolves from the emitted metadata. */
|
|
61
|
+
const isClassToken = (context, annotation) => {
|
|
62
|
+
if (annotation.type !== "TSTypeReference") return false
|
|
63
|
+
const { checker, toTs } = typed(context)
|
|
64
|
+
const tsNode = toTs(annotation)
|
|
65
|
+
if (!tsNode) return false
|
|
66
|
+
const type = checker.getTypeFromTypeNode(tsNode)
|
|
67
|
+
const symbol = type.getSymbol()
|
|
68
|
+
if (!symbol || !(symbol.flags & ts.SymbolFlags.Class)) return false
|
|
69
|
+
const declaration = symbol.declarations?.find((entry) => ts.isClassDeclaration(entry) || ts.isClassExpression(entry))
|
|
70
|
+
return Boolean(declaration)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A provider's constructor parameter is a class token or carries a named-token injector. */
|
|
74
|
+
export const providerParamToken = {
|
|
75
|
+
meta: {
|
|
76
|
+
type: "problem",
|
|
77
|
+
docs: { description: "A constructor parameter of a Nest provider is typed by a class or carries an `Inject<Thing>()` decorator bound to a named token." },
|
|
78
|
+
schema: [],
|
|
79
|
+
messages: {
|
|
80
|
+
untokened:
|
|
81
|
+
"`{{name}}` is injected with no token Nest can resolve: its type `{{type}}` is not a class and the parameter carries no `Inject<Thing>()`. A primitive, a `Map`, a union, a function type or an interface has no runtime identity. Declare a `unique symbol` token and an `Inject<Thing>()` in the owner's `<owner>.decorators.ts` and decorate the parameter, or inject the class that owns the value.",
|
|
82
|
+
unannotated:
|
|
83
|
+
"`{{name}}` is injected without a type annotation, so nothing names its token. Annotate it with a class, or decorate it with the owner's `Inject<Thing>()`.",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
create(context) {
|
|
87
|
+
const sourceCode = context.sourceCode
|
|
88
|
+
const check = (classNode) => {
|
|
89
|
+
if (!isNestClass(context, classNode)) return
|
|
90
|
+
for (const member of classNode.body.body) {
|
|
91
|
+
if (member.type !== "MethodDefinition" || member.kind !== "constructor" || !member.value?.params) continue
|
|
92
|
+
for (const param of member.value.params) {
|
|
93
|
+
const { target, decorators } = paramParts(param)
|
|
94
|
+
if (decorators.some((decorator) => isInjector(context, decorator))) continue
|
|
95
|
+
const name = target.type === "Identifier" ? target.name : sourceCode.getText(target)
|
|
96
|
+
const annotation = target.typeAnnotation?.typeAnnotation
|
|
97
|
+
if (!annotation) context.report({ node: target, messageId: "unannotated", data: { name } })
|
|
98
|
+
else if (!isClassToken(context, annotation)) context.report({ node: target, messageId: "untokened", data: { name, type: sourceCode.getText(annotation) } })
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return { ClassDeclaration: check, ClassExpression: check }
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// -- no-repository-class ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
/** The typeorm types whose presence in a member makes the class a persistence class. */
|
|
109
|
+
const PERSISTENCE_TYPES = new Set(["EntityManager", "Repository", "TreeRepository", "MongoRepository", "DataSource", "QueryRunner"])
|
|
110
|
+
|
|
111
|
+
/** The persistence type a written type annotation names (by declaration origin), else null. */
|
|
112
|
+
const persistenceTypeOf = (context, annotation) => {
|
|
113
|
+
if (!annotation) return null
|
|
114
|
+
const origin = typeOrigins(context, annotation).find((entry) => entry.module === "typeorm" && PERSISTENCE_TYPES.has(entry.name))
|
|
115
|
+
return origin ? origin.name : null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The first member of a class that takes, holds or returns a persistence type: `{ member, type }`, else null. */
|
|
119
|
+
const persistenceMember = (context, classNode) => {
|
|
120
|
+
for (const member of classNode.body.body) {
|
|
121
|
+
if (member.type === "PropertyDefinition" || member.type === "AccessorProperty") {
|
|
122
|
+
const type = persistenceTypeOf(context, member.typeAnnotation?.typeAnnotation)
|
|
123
|
+
if (type) return { member, type }
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (member.type !== "MethodDefinition" || !member.value) continue
|
|
127
|
+
const fn = member.value
|
|
128
|
+
for (const param of fn.params ?? []) {
|
|
129
|
+
const { target } = paramParts(param)
|
|
130
|
+
const type = persistenceTypeOf(context, target.typeAnnotation?.typeAnnotation)
|
|
131
|
+
if (type) return { member, type }
|
|
132
|
+
}
|
|
133
|
+
const returned = persistenceTypeOf(context, fn.returnType?.typeAnnotation)
|
|
134
|
+
if (returned) return { member, type: returned }
|
|
135
|
+
}
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A persistence class exists only where the manager may be held. */
|
|
140
|
+
export const noRepositoryClass = {
|
|
141
|
+
meta: {
|
|
142
|
+
type: "problem",
|
|
143
|
+
docs: { description: "A class with a member typed `EntityManager`, `Repository`, `DataSource` or `QueryRunner` lives only in an application handler, a domain service, a platform persistence capability, the migrate app, the test world or a migration." },
|
|
144
|
+
schema: [],
|
|
145
|
+
messages: {
|
|
146
|
+
statements:
|
|
147
|
+
"`{{name}}` is exported and takes an `EntityManager` as its first parameter: a statement module is a repository without a class. Put the SQL text in `<name>.sql.ts` and run it with `this.entityManager.query(...)` from the capability `*.service.ts` or the application `*.handler.ts`; only `platform/database`, `apps/migrate` and `src/tests` take a manager in a free function.",
|
|
148
|
+
repository:
|
|
149
|
+
"`{{name}}` takes or holds a `{{type}}` in `{{member}}` but is not an application handler, a domain service or a platform persistence capability. A class wrapping the manager per entity is a repository whatever it is called: it hides which connection and transaction a call runs in and cannot be unit-tested with a token double. Move the queries into the handler or the domain service that owns the use, and call the injected EntityManager directly.",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
create(context) {
|
|
153
|
+
const hfs = hfsOf(context)
|
|
154
|
+
const filename = context.filename || context.getFilename()
|
|
155
|
+
const file = normalizePath(filename)
|
|
156
|
+
if (mayHoldEntityManager(hfs, file) || inMigrateApp(hfs, file) || inTestBootstrap(hfs, file) || isMigrationFile(hfs, file) || (hfs.slotOf(file) ?? "").startsWith("be.tests.")) return {}
|
|
157
|
+
const check = (node) => {
|
|
158
|
+
if (implementsMigration(context, node)) return
|
|
159
|
+
const found = persistenceMember(context, node)
|
|
160
|
+
if (!found) return
|
|
161
|
+
const key = found.member.key
|
|
162
|
+
const member = found.member.kind === "constructor" ? "constructor" : key?.type === "Identifier" ? key.name : "a member"
|
|
163
|
+
context.report({ node: node.id ?? node, messageId: "repository", data: { name: node.id?.name ?? "this class", type: found.type, member } })
|
|
164
|
+
}
|
|
165
|
+
/** An exported function whose first parameter is an EntityManager is a statement module: the same wrapper without a class. */
|
|
166
|
+
const checkFunction = (fn, id) => {
|
|
167
|
+
const first = fn.params?.[0]
|
|
168
|
+
if (!first) return
|
|
169
|
+
const { target } = paramParts(first)
|
|
170
|
+
if (persistenceTypeOf(context, target.typeAnnotation?.typeAnnotation) !== "EntityManager") return
|
|
171
|
+
context.report({ node: id, messageId: "statements", data: { name: id.name ?? "this function" } })
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
ClassDeclaration: check,
|
|
175
|
+
ClassExpression: check,
|
|
176
|
+
ExportNamedDeclaration(node) {
|
|
177
|
+
const declaration = node.declaration
|
|
178
|
+
if (declaration?.type === "FunctionDeclaration" && declaration.id) checkFunction(declaration, declaration.id)
|
|
179
|
+
if (declaration?.type === "VariableDeclaration") {
|
|
180
|
+
for (const entry of declaration.declarations) {
|
|
181
|
+
if (entry.id.type === "Identifier" && entry.init && (entry.init.type === "ArrowFunctionExpression" || entry.init.type === "FunctionExpression")) checkFunction(entry.init, entry.id)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// -- no-test-double-in-source ----------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/** The words that mark a test double. */
|
|
192
|
+
const DOUBLE_WORDS = new Set(["mock", "fake", "stub"])
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The words of an identifier: `MockExpertAgentService` gives `Mock Expert Agent Service`, `fakeClock` gives `fake Clock`,
|
|
196
|
+
* `FAKE_CLOCK` gives `FAKE CLOCK`, `HTTPStub` gives `HTTP Stub`. A word is whole: `Faker` and `Mockingbird` are not `Fake`
|
|
197
|
+
* and `Mock`.
|
|
198
|
+
*/
|
|
199
|
+
const wordsOf = (name) =>
|
|
200
|
+
name
|
|
201
|
+
.split(/[^A-Za-z0-9]+/)
|
|
202
|
+
.flatMap((part) => part.match(/[A-Z]+(?![a-z])|[A-Z]?[a-z]+|[0-9]+/g) ?? [])
|
|
203
|
+
|
|
204
|
+
/** The double marker word an identifier carries, else null. */
|
|
205
|
+
export const doubleWordOf = (name) => wordsOf(name).find((word) => DOUBLE_WORDS.has(word.toLowerCase())) ?? null
|
|
206
|
+
|
|
207
|
+
/** Production source names no test double. */
|
|
208
|
+
export const noTestDoubleInSource = {
|
|
209
|
+
meta: {
|
|
210
|
+
type: "problem",
|
|
211
|
+
docs: { description: "A class, function or constant of production source is not named `Mock*`, `Fake*` or `Stub*`." },
|
|
212
|
+
schema: [],
|
|
213
|
+
messages: {
|
|
214
|
+
double:
|
|
215
|
+
"`{{name}}` carries the test-double word `{{word}}` in production source. A double belongs in a spec or `src/tests`; a production offline or deterministic driver is an options-driven adapter of the port (its behaviour chosen by the module's options), renamed for what it does (`TemplateCatalogAdapter`, `OfflineDrafter`), and selected by configuration, never by being a mock.",
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
create(context) {
|
|
219
|
+
const hfs = hfsOf(context)
|
|
220
|
+
const filename = context.filename || context.getFilename()
|
|
221
|
+
if (isSpecFile(filename) || (hfs.slotOf(filename) ?? "").startsWith("be.tests.")) return {}
|
|
222
|
+
const report = (id) => {
|
|
223
|
+
const word = doubleWordOf(id.name)
|
|
224
|
+
if (word) context.report({ node: id, messageId: "double", data: { name: id.name, word } })
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
ClassDeclaration(node) { if (node.id) report(node.id) },
|
|
228
|
+
ClassExpression(node) { if (node.id) report(node.id) },
|
|
229
|
+
FunctionDeclaration(node) { if (node.id) report(node.id) },
|
|
230
|
+
VariableDeclarator(node) {
|
|
231
|
+
if (node.id.type === "Identifier" && node.parent?.parent?.type === "ExportNamedDeclaration") report(node.id)
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The rules this law contributes to the plugin. */
|
|
238
|
+
export const rules = {
|
|
239
|
+
"provider-param-token": providerParamToken,
|
|
240
|
+
"no-repository-class": noRepositoryClass,
|
|
241
|
+
"no-test-double-in-source": noTestDoubleInSource,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Every rule of this law ships at `error`. */
|
|
245
|
+
export const recommended = {
|
|
246
|
+
"starci-be/provider-param-token": "error",
|
|
247
|
+
"starci-be/no-repository-class": "error",
|
|
248
|
+
"starci-be/no-test-double-in-source": "error",
|
|
249
|
+
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"scripts": {
|
|
3
|
-
"build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json",
|
|
4
|
-
{{appScripts}}
|
|
5
|
-
"typecheck": "tsc -p tsconfig.json",
|
|
6
|
-
"typecheck:tests": "tsc -p src/tests/tsconfig.json",
|
|
7
|
-
"lint": "hfs lint",
|
|
8
|
-
"lint:fix": "hfs lint --fix",
|
|
9
|
-
"format": "prettier --write .",
|
|
10
|
-
"format:check": "prettier --check .",
|
|
11
|
-
"test": "jest --selectProjects unit --coverage",
|
|
12
|
-
"test:affected": "jest --selectProjects unit --passWithNoTests",
|
|
13
|
-
"test:integration": "npm run typecheck:tests && jest --selectProjects integration",
|
|
14
|
-
"test:e2e": "npm run typecheck:tests && jest --selectProjects e2e",
|
|
15
|
-
"test:contract": "npm run typecheck:tests && jest --selectProjects contract",
|
|
16
|
-
"test:stack": "starci-test-stack",
|
|
17
|
-
"contract:emit": "hfs emit-contracts"
|
|
18
|
-
}
|
|
19
|
-
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"scripts": {
|
|
3
|
-
"prepare": "husky",
|
|
4
|
-
{{appScripts}}
|
|
5
|
-
"codegen": "npm run codegen --workspaces --if-present",
|
|
6
|
-
"build": "npm run build --workspaces --if-present",
|
|
7
|
-
"typecheck": "npm run typecheck --workspaces --if-present",
|
|
8
|
-
"lint": "npm run codegen --silent && hfs lint --stylelint \"{{styleGlob}}\"",
|
|
9
|
-
"lint:fix": "npm run codegen --silent && hfs lint --fix --stylelint \"{{styleGlob}}\"",
|
|
10
|
-
"format": "prettier --write .",
|
|
11
|
-
"format:check": "prettier --check ."
|
|
12
|
-
}
|
|
13
|
-
}
|