@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +8 -7
  3. package/comments.mjs +104 -1
  4. package/config-owner.mjs +113 -13
  5. package/connections.mjs +1 -12
  6. package/cqrs.mjs +68 -0
  7. package/data-access.mjs +100 -4
  8. package/error-handling.mjs +107 -1
  9. package/index.mjs +2 -0
  10. package/infra-owner.mjs +39 -0
  11. package/input-bounds.mjs +64 -2
  12. package/lib/config.mjs +1 -1
  13. package/lib/hfs.mjs +7 -6
  14. package/lib/persistence.mjs +29 -0
  15. package/log-safety.mjs +89 -2
  16. package/package.json +1 -1
  17. package/runtime/knowledge/hfs/canon-pins.yaml +28 -9
  18. package/runtime/knowledge/hfs/slots.yaml +193 -128
  19. package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
  20. package/runtime/packages/hfs/templates/app/package-scripts/package.json +22 -0
  21. package/runtime/scripts/checks/architecture/config.mjs +31 -11
  22. package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
  23. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
  24. package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
  25. package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
  26. package/runtime/scripts/checks/architecture/hfs.mjs +104 -66
  27. package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
  28. package/runtime/scripts/checks/architecture/registration.mjs +1 -1
  29. package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
  30. package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
  31. package/runtime/scripts/checks/architecture/typescript.mjs +45 -20
  32. package/runtime/scripts/checks/typescript-programs.mjs +2 -2
  33. package/runtime/scripts/lib/hfs-path-findings.mjs +13 -2
  34. package/runtime/scripts/lib/hfs-slots.mjs +244 -61
  35. package/runtime/scripts/lib/hfs-view.mjs +9 -7
  36. package/runtime/scripts/lib/language.mjs +11 -1
  37. package/service-deps.mjs +249 -0
  38. package/runtime/packages/hfs/templates/be/package-scripts/package.json +0 -19
  39. package/runtime/packages/hfs/templates/fe/package-scripts/package.json +0 -13
@@ -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
- }