@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/data-access.mjs CHANGED
@@ -1,254 +1,264 @@
1
1
  /**
2
- * The rules that hold `data-access.md`.
2
+ * The rules that hold `data-access.md` and the persistence half of the back-end convention (R82, R83).
3
3
  *
4
- * The first three read a constructor parameter or a decorator, all exact - there is no heuristic
5
- * here and no judgement to get wrong, which is why all three can be switched on without a burn-down
6
- * in a codebase that already follows them.
4
+ * The database is reached one way: the shared TypeORM `EntityManager`, injected through the named injector of a declared
5
+ * connection (`Inject<Pascal(connection)>EntityManager`), called directly. Every rule here identifies its receiver by its
6
+ * TypeScript type (`isPackageType(..., "typeorm")`) and every path question goes through the HFS slot view: no variable
7
+ * name, no path regular expression, no spec exemption.
7
8
  *
8
- * The next two hold a narrower, exact slice of DATA-4 (Rule 5) and DATA-5 (Rule 6) - the slice of
9
- * each that a single file settles on its own, with no call graph and no knowledge of intent needed.
10
- *
11
- * `no-outer-manager-in-transaction` catches the one shape of DATA-4 a parser can see: a
12
- * `this.<field>.transaction(async (tx) => ...)` callback whose body reaches for `this.<field>`
13
- * again instead of `tx`. That field held the manager from BEFORE the transaction opened, so a call
14
- * through it runs and commits on its own - the exact "half of it was written" bug DATA-4 names. What
15
- * it does NOT see, and cannot: whether a HELPER defined in another file was handed `tx` by ITS
16
- * caller. That still needs the call graph and stays undecidable, same as the law says.
17
- *
18
- * `no-eager-relation` catches the one shape of DATA-5 a parser can see: `eager: true` written on a
19
- * relation decorator. What it does NOT see: whether a call site correctly asked for a relation it
20
- * needed. That still needs to know what the answer is for and stays undecidable, same as the law
21
- * says.
22
- *
23
- * Both remaining gaps are read by a person; a rule that guessed at either would fire on correct
24
- * code, which is how a correct rule gets disabled.
25
- *
26
- * `no-external-call-in-transaction` (R82 `BE_TRANSACTION_EXTERNAL_CALL`) refuses `fetch`, an HTTP
27
- * client call or any method of a `*Client`/`*Sdk` receiver written inside `.transaction(async (tx) =>
28
- * ...)`. Holding the transaction open for as long as the external call takes means a failure after
29
- * partial commit, or a success the rollback then reverses with no way back; the fix is to commit
30
- * first and call out after, or to write an outbox message inside the transaction instead.
31
- *
32
- * `named-entity-manager-only` (R83 `BE_UNNAMED_DATA_ACCESS`) holds the one way a use case reaches the
33
- * database: the shared `EntityManager` injected through a named injector and called directly. It refuses a bare
34
- * `@InjectEntityManager()`, any `.getRepository(...)` call, and an injected `DataSource` outside the platform
35
- * database module and `apps/migrate`. `no-injected-repository` refuses `@InjectRepository` and `Repository<T>` in the same spirit.
9
+ * - `must-inject-entity-manager` - a constructor parameter typed `EntityManager` carries the named injector of a declared
10
+ * connection. A lookalike (`InjectFooEntityManager`) or a bare `InjectEntityManager()` is not one.
11
+ * - `named-entity-manager-only` - no property injection of an `EntityManager`, `DataSource` or `QueryRunner`; a
12
+ * `DataSource` or `QueryRunner` is injected only in the platform database capability and the migrate app; no
13
+ * `getRepository(...)` on a manager or data source.
14
+ * - `no-injected-repository` - no `@InjectRepository`, no `Repository<T>` type, no `TypeOrmModule.forFeature`.
15
+ * - `require-entity-table-name` - `@Entity()` names its table.
16
+ * - `no-outer-manager-in-transaction` - inside `<manager>.transaction(async (tx) => ...)` every call uses `tx`; any other
17
+ * value typed `EntityManager` that was declared outside the callback is refused.
18
+ * - `no-eager-relation` - a relation decorator carries no `eager: true`.
19
+ * - `no-external-call-in-transaction` (R82) - inside a transaction callback, no call through a receiver typed from
20
+ * `integrations/**`, `HttpClient` or `MessagePublisher`, and no `fetch`.
36
21
  */
37
22
 
38
23
  import { walk } from "./lib/ast.mjs"
39
- import { normalizePath } from "./lib/path.mjs"
40
-
41
- /** A parameter property (`private readonly x: T`) wraps the parameter it declares. */
42
- const unwrapParam = (param) => (param.type === "TSParameterProperty" ? param.parameter : param)
43
-
44
- /** The type name a parameter is annotated with, or null. */
45
- const paramTypeName = (param) => {
46
- const annotation = param.typeAnnotation && param.typeAnnotation.typeAnnotation
47
- if (!annotation || annotation.type !== "TSTypeReference" || annotation.typeName.type !== "Identifier") return null
48
- return annotation.typeName.name
24
+ import { hfsOf } from "./lib/hfs.mjs"
25
+ import {
26
+ declaredInjectorNames,
27
+ inDatabaseCapability,
28
+ inMigrateApp, inTestBootstrap,
29
+ infraTypeOf,
30
+ injectionSites,
31
+ } from "./lib/persistence.mjs"
32
+ import { isPackageType, typeOrigins } from "./lib/types.mjs"
33
+
34
+ /** The `typeorm` repository types no class receives. */
35
+ const REPOSITORY_TYPES = ["Repository", "TreeRepository", "MongoRepository"]
36
+
37
+ // -- R83: the injector ---------------------------------------------------------------------------------
38
+
39
+ /** A constructor parameter typed `EntityManager` carries the named injector of a declared connection. */
40
+ export const mustInjectEntityManager = {
41
+ meta: {
42
+ type: "problem",
43
+ docs: { description: "An injected `EntityManager` carries the named injector of a declared connection." },
44
+ schema: [],
45
+ messages: {
46
+ undecorated:
47
+ "`EntityManager` is injected without a named injector. The type alone does not say WHICH database this is; only `{{expected}}` names a declared connection (from `hfs.json`), and a lookalike or a bare `InjectEntityManager()` names none. Inject it with the injector of the connection this class reads.",
48
+ },
49
+ },
50
+ create(context) {
51
+ const hfs = hfsOf(context)
52
+ const expected = declaredInjectorNames(hfs)
53
+ return injectionSites(({ node, kind, decorators, annotation }) => {
54
+ if (kind !== "param" || !annotation || infraTypeOf(context, annotation) !== "EntityManager") return
55
+ if (decorators.some((name) => expected.includes(name))) return
56
+ context.report({ node, messageId: "undecorated", data: { expected: expected.join("` or `") || "no injector (declare the connection in hfs.json)" } })
57
+ })
58
+ },
49
59
  }
50
60
 
51
- /** Decorator names on a parameter, including ones carried by the parameter property wrapping it. */
52
- const paramDecorators = (original) => {
53
- const carriers = [original]
54
- if (original.type === "TSParameterProperty") carriers.push(original.parameter)
55
- const names = []
56
- for (const carrier of carriers) {
57
- for (const decorator of carrier.decorators || []) {
58
- const expression = decorator.expression
59
- if (expression.type === "CallExpression" && expression.callee.type === "Identifier") {
60
- names.push(expression.callee.name)
61
- } else if (expression.type === "Identifier") {
62
- names.push(expression.name)
63
- }
64
- }
65
- }
66
- return names
61
+ /** The shared manager is injected by name, as a constructor parameter, and called directly. */
62
+ export const namedEntityManagerOnly = {
63
+ meta: {
64
+ type: "problem",
65
+ docs: {
66
+ description:
67
+ "No property injection of an `EntityManager`, `DataSource` or `QueryRunner`; a `DataSource` or `QueryRunner` only in the platform database capability, the migrate app and the test world (`src/tests/world`); no `getRepository`.",
68
+ },
69
+ schema: [],
70
+ messages: {
71
+ property:
72
+ "`{{type}}` is injected as a class property. Property injection hides the dependency from the constructor and from the composition spec. Receive the named-injector `EntityManager` as a constructor parameter.",
73
+ infra:
74
+ "`{{type}}` is injected here. A `DataSource` or `QueryRunner` is built and held only by the platform database capability, the migrate app and the test world in `src/tests/world`; specs take `world.db.<connection>`; everything else injects the shared `EntityManager` through its named injector and calls it directly.",
75
+ getRepository:
76
+ "`.getRepository(...)` binds a handle to ONE entity and hides which connection it came from. Call the shared manager directly: `manager.find(Entity, ...)`, `manager.save(Entity, ...)`, `tx.insert(Entity, ...)`.",
77
+ },
78
+ },
79
+ create(context) {
80
+ const hfs = hfsOf(context)
81
+ const filename = context.filename || context.getFilename()
82
+ const mayHoldConnections = inDatabaseCapability(hfs, filename) || inMigrateApp(hfs, filename) || inTestBootstrap(hfs, filename)
83
+ return {
84
+ ...injectionSites(({ node, kind, annotation }) => {
85
+ if (!annotation) return
86
+ const type = infraTypeOf(context, annotation)
87
+ if (!type) return
88
+ if (kind === "property") context.report({ node, messageId: "property", data: { type } })
89
+ else if (type !== "EntityManager" && !mayHoldConnections) context.report({ node, messageId: "infra", data: { type } })
90
+ }),
91
+ CallExpression(node) {
92
+ const callee = node.callee
93
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return
94
+ if (callee.property.name !== "getRepository") return
95
+ if (infraTypeOf(context, callee.object)) context.report({ node, messageId: "getRepository" })
96
+ },
97
+ }
98
+ },
67
99
  }
68
100
 
69
- /** Every parameter of a constructor definition. */
70
- const constructorParams = (node) =>
71
- node.kind === "constructor" && node.value && node.value.params ? node.value.params : []
72
-
73
- /** Repository types that bind a handle to one entity. */
74
- const REPOSITORY_TYPES = /^(?:Repository|TreeRepository|MongoRepository)$/
101
+ // -- R83: repositories ---------------------------------------------------------------------------------
75
102
 
76
- /** The house decorator family that names a datasource. */
77
- const NAMES_DATASOURCE = /^Inject\w*EntityManager$/
78
-
79
- // -- DATA-1 ----------------------------------------------------------------------------------------
80
-
81
- /** An injected manager names the datasource it belongs to. */
82
- export const mustInjectEntityManager = {
83
- meta: {
84
- type: "problem",
85
- docs: { description: "An injected `EntityManager` names its datasource through a decorator." },
86
- schema: [],
87
- messages: {
88
- undecorated:
89
- "`EntityManager` is injected without an `@Inject*EntityManager()` decorator. The type alone does not say WHICH datasource this is, and this application has more than one - so this reads correctly while pointing at the wrong database. Name the connection at the injection site.",
90
- },
91
- },
92
- create(context) {
93
- return {
94
- MethodDefinition(node) {
95
- for (const original of constructorParams(node)) {
96
- const param = unwrapParam(original)
97
- if (paramTypeName(param) !== "EntityManager") continue
98
- if (paramDecorators(original).some((name) => NAMES_DATASOURCE.test(name))) continue
99
- context.report({ node: param, messageId: "undecorated" })
100
- }
101
- },
103
+ /** Whether a type reference sits on a parameter or property already reported through its `@InjectRepository` decorator. */
104
+ const carriesInjectRepository = (node) => {
105
+ for (let owner = node.parent; owner && owner.type !== "ClassBody"; owner = owner.parent) {
106
+ const decorated = (owner.decorators ?? []).some((decorator) => decorator.expression.type === "CallExpression" && decorator.expression.callee.name === "InjectRepository")
107
+ if (decorated) return true
102
108
  }
103
- },
109
+ return false
104
110
  }
105
111
 
106
- // -- DATA-2 ----------------------------------------------------------------------------------------
107
-
108
112
  /** Persistence never arrives as a repository. */
109
113
  export const noInjectedRepository = {
110
- meta: {
111
- type: "problem",
112
- docs: { description: "Persistence goes through the shared `EntityManager`, never an injected repository." },
113
- schema: [],
114
- messages: {
115
- repo:
116
- "Inject the shared EntityManager with the named injector and call it directly. A repository is bound to ONE entity, so it cannot carry a transaction into a second table - and a use case that grows a second write then has to be rewritten rather than extended.",
114
+ meta: {
115
+ type: "problem",
116
+ docs: { description: "Persistence goes through the shared `EntityManager`, never a repository." },
117
+ schema: [],
118
+ messages: {
119
+ repo:
120
+ "Inject the shared EntityManager with the named injector and call it directly. A repository is bound to ONE entity, so it cannot carry a transaction into a second table - and a use case that grows a second write then has to be rewritten rather than extended.",
121
+ forFeature:
122
+ "`TypeOrmModule.forFeature(...)` registers repositories, and this back end has none. Register the entities once on the connection in the platform database capability and call the shared EntityManager.",
123
+ },
117
124
  },
118
- },
119
- create(context) {
120
- return {
121
- // `@InjectRepository(...)` is refused wherever it is written: a constructor parameter, a property, a method parameter.
122
- Decorator(node) {
123
- const expression = node.expression
124
- if (expression.type === "CallExpression" && expression.callee.type === "Identifier" && expression.callee.name === "InjectRepository") {
125
- context.report({ node, messageId: "repo" })
126
- }
127
- },
128
- MethodDefinition(node) {
129
- for (const original of constructorParams(node)) {
130
- const param = unwrapParam(original)
131
- // A decorated parameter is reported once, by the Decorator visitor.
132
- if (paramDecorators(original).includes("InjectRepository")) continue
133
- if (REPOSITORY_TYPES.test(paramTypeName(param) || "")) context.report({ node: param, messageId: "repo" })
125
+ create(context) {
126
+ return {
127
+ Decorator(node) {
128
+ const expression = node.expression
129
+ if (expression.type === "CallExpression" && expression.callee.type === "Identifier" && expression.callee.name === "InjectRepository") {
130
+ context.report({ node, messageId: "repo" })
131
+ }
132
+ },
133
+ TSTypeReference(node) {
134
+ if (!REPOSITORY_TYPES.some((name) => isPackageType(context, node, name, "typeorm"))) return
135
+ if (!carriesInjectRepository(node)) context.report({ node, messageId: "repo" })
136
+ },
137
+ CallExpression(node) {
138
+ const callee = node.callee
139
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return
140
+ if (callee.property.name === "forFeature" && isPackageType(context, callee.object, "TypeOrmModule", "@nestjs/typeorm")) {
141
+ context.report({ node, messageId: "forFeature" })
142
+ }
143
+ },
134
144
  }
135
- },
136
- }
137
- },
145
+ },
138
146
  }
139
147
 
140
148
  // -- DATA-3 ----------------------------------------------------------------------------------------
141
149
 
142
150
  /** An entity names its table, so a class rename cannot become a dropped table. */
143
151
  export const requireEntityTableName = {
144
- meta: {
145
- type: "problem",
146
- docs: { description: "`@Entity()` names its table explicitly." },
147
- schema: [],
148
- messages: {
149
- inferred:
150
- "`@Entity()` here lets the ORM infer the table name from the class name, so renaming the class renames the table - which `synchronize` performs as a DROP and CREATE rather than a migration. A class rename is a refactor; a dropped table is an outage. Name the table.",
152
+ meta: {
153
+ type: "problem",
154
+ docs: { description: "`@Entity()` names its table explicitly." },
155
+ schema: [],
156
+ messages: {
157
+ inferred:
158
+ "`@Entity()` here lets the ORM infer the table name from the class name, so renaming the class renames the table - which `synchronize` performs as a DROP and CREATE rather than a migration. A class rename is a refactor; a dropped table is an outage. Name the table.",
159
+ },
160
+ },
161
+ create(context) {
162
+ /** Both `@Entity("t")` and `@Entity({ name: "t" })` name the table. */
163
+ const isTableName = (argument) =>
164
+ (argument.type === "Literal" && typeof argument.value === "string") || argument.type === "TemplateLiteral"
165
+ return {
166
+ Decorator(node) {
167
+ const expression = node.expression
168
+ if (expression.type !== "CallExpression") return
169
+ if (expression.callee.type !== "Identifier" || expression.callee.name !== "Entity") return
170
+ // The options form is not a stylistic variant to discourage: it is the ONLY form that can
171
+ // also carry a schema qualifier, so rejecting it would push an author to delete the schema
172
+ // to satisfy the rule - a worse outcome than the inferred name this exists to prevent.
173
+ const named = expression.arguments.some(
174
+ (argument) =>
175
+ isTableName(argument)
176
+ || (argument.type === "ObjectExpression"
177
+ && argument.properties.some(
178
+ (property) =>
179
+ property.type === "Property"
180
+ && !property.computed
181
+ && (property.key.name === "name" || property.key.value === "name")
182
+ && isTableName(property.value),
183
+ )),
184
+ )
185
+ if (!named) context.report({ node, messageId: "inferred" })
186
+ },
187
+ }
151
188
  },
152
- },
153
- create(context) {
154
- /** Both `@Entity("t")` and `@Entity({ name: "t" })` name the table. */
155
- const isTableName = (argument) =>
156
- (argument.type === "Literal" && typeof argument.value === "string") || argument.type === "TemplateLiteral"
157
- return {
158
- Decorator(node) {
159
- const expression = node.expression
160
- if (expression.type !== "CallExpression") return
161
- if (expression.callee.type !== "Identifier" || expression.callee.name !== "Entity") return
162
- // The options form is not a stylistic variant to discourage: it is the ONLY form that can
163
- // also carry a schema qualifier, so rejecting it would push an author to delete the schema
164
- // to satisfy the rule - a worse outcome than the inferred name this exists to prevent.
165
- const named = expression.arguments.some(
166
- (argument) =>
167
- isTableName(argument)
168
- || (argument.type === "ObjectExpression"
169
- && argument.properties.some(
170
- (property) =>
171
- property.type === "Property"
172
- && !property.computed
173
- && (property.key.name === "name" || property.key.value === "name")
174
- && isTableName(property.value),
175
- )),
176
- )
177
- if (!named) context.report({ node, messageId: "inferred" })
178
- },
179
- }
180
- },
181
189
  }
182
190
 
183
- // -- DATA-4 (Rule 5) --------------------------------------------------------------------------------
184
-
185
- /** Node types that rebind `this` - a `this.<field>` read past one of these belongs to a different call. */
186
- const REBINDS_THIS = /^(?:FunctionDeclaration|FunctionExpression|ClassDeclaration|ClassExpression)$/
191
+ // -- transactions ----------------------------------------------------------------------------------
192
+
193
+ /** The callback of a `.transaction(...)` call on a manager or data source, or null. */
194
+ const transactionCallback = (context, node) => {
195
+ const callee = node.callee
196
+ if (callee.type !== "MemberExpression" || callee.computed) return null
197
+ if (callee.property.type !== "Identifier" || callee.property.name !== "transaction") return null
198
+ const receiver = infraTypeOf(context, callee.object)
199
+ if (receiver !== "EntityManager" && receiver !== "DataSource") return null
200
+ const callback = node.arguments[node.arguments.length - 1]
201
+ return callback && (callback.type === "ArrowFunctionExpression" || callback.type === "FunctionExpression") ? callback : null
202
+ }
187
203
 
188
- /** Walk every descendant of `node` that still runs under the SAME `this`, calling `visit` on each. */
189
- const walkPreservingThis = (node, visit) => {
190
- if (!node || typeof node.type !== "string") return
191
- visit(node)
192
- for (const key of Object.keys(node)) {
193
- if (key === "parent") continue
194
- const value = node[key]
195
- if (Array.isArray(value)) {
196
- for (const child of value) {
197
- if (child && typeof child.type === "string" && !REBINDS_THIS.test(child.type)) walkPreservingThis(child, visit)
198
- }
199
- } else if (value && typeof value.type === "string" && !REBINDS_THIS.test(value.type)) {
200
- walkPreservingThis(value, visit)
204
+ /** The variable an identifier resolves to, looking outward from the identifier's own scope. */
205
+ const resolveVariable = (sourceCode, node) => {
206
+ for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
207
+ const variable = scope.set.get(node.name)
208
+ if (variable) return variable
201
209
  }
202
- }
210
+ return null
211
+ }
212
+
213
+ /** The leftmost expression of a member chain: `this.deps.em` gives the `this`. */
214
+ const rootOf = (expression) => {
215
+ let current = expression
216
+ while (current.type === "MemberExpression" || current.type === "TSNonNullExpression") current = current.type === "MemberExpression" ? current.object : current.expression
217
+ return current
218
+ }
219
+
220
+ /** Whether an identifier position reads a value (a property name, a key or a type name does not). */
221
+ const readsValue = (node) => {
222
+ const parent = node.parent
223
+ if (parent.type === "MemberExpression" && parent.property === node && !parent.computed) return false
224
+ if (parent.type === "Property" && parent.key === node && !parent.computed && !parent.shorthand) return false
225
+ return !parent.type.startsWith("TS")
203
226
  }
204
227
 
205
- /** Everything inside a transaction receives the transactional manager, not the outer injected one. */
228
+ /** Everything inside a transaction receives the transactional manager, not one declared outside it. */
206
229
  export const noOuterManagerInTransaction = {
207
- meta: {
208
- type: "problem",
209
- docs: {
210
- description:
211
- "Inside `this.<field>.transaction(async (tx) => ...)`, every call uses `tx` - never `this.<field>` again (data-access.md DATA-4, Rule 5).",
230
+ meta: {
231
+ type: "problem",
232
+ docs: { description: "Inside `<manager>.transaction(async (tx) => ...)`, every call uses `tx`, never another `EntityManager` declared outside the callback." },
233
+ schema: [],
234
+ messages: {
235
+ outerManager:
236
+ "`{{name}}` is an EntityManager from OUTSIDE this transaction callback. A call through it runs and commits on its own, which is how half a write survives a rollback. Use the manager the callback received.",
237
+ },
212
238
  },
213
- schema: [],
214
- messages: {
215
- outerManager:
216
- "This is inside `this.{{field}}.transaction(...)`, but it reaches for `this.{{field}}` again instead of `{{param}}`, the manager the callback received. `this.{{field}}` is the manager from BEFORE the transaction opened - a call through it runs and commits on its own, which is how half a write survives a rollback. Use `{{param}}`.",
239
+ create(context) {
240
+ const sourceCode = context.sourceCode || context.getSourceCode()
241
+ /** Whether the value written at `node` was declared outside the callback spanning `range`. */
242
+ const declaredOutside = (node, range) => {
243
+ const root = rootOf(node)
244
+ if (root.type === "ThisExpression") return true
245
+ if (root.type !== "Identifier") return false
246
+ const variable = resolveVariable(sourceCode, root)
247
+ const declaration = variable?.defs[0]?.name
248
+ return Boolean(declaration) && (declaration.range[0] < range[0] || declaration.range[1] > range[1])
249
+ }
250
+ return {
251
+ CallExpression(node) {
252
+ const callback = transactionCallback(context, node)
253
+ if (!callback) return
254
+ walk(callback.body, (child) => {
255
+ const isReference = child.type === "MemberExpression" || (child.type === "Identifier" && readsValue(child))
256
+ if (!isReference || infraTypeOf(context, child) !== "EntityManager") return
257
+ if (declaredOutside(child, callback.range)) context.report({ node: child, messageId: "outerManager", data: { name: sourceCode.getText(child) } })
258
+ })
259
+ },
260
+ }
217
261
  },
218
- },
219
- create(context) {
220
- return {
221
- CallExpression(node) {
222
- const callee = node.callee
223
- if (callee.type !== "MemberExpression" || callee.computed) return
224
- if (callee.property.type !== "Identifier" || callee.property.name !== "transaction") return
225
- const object = callee.object
226
- if (object.type !== "MemberExpression" || object.computed) return
227
- if (object.object.type !== "ThisExpression") return
228
- if (object.property.type !== "Identifier") return
229
- const field = object.property.name
230
- // Only the arrow-function callback is covered. A plain `function (tx) {}` callback does not
231
- // inherit `this` from the enclosing method at all - TypeORM invokes it with no receiver, so
232
- // `this.<field>` inside one throws rather than silently escaping the transaction, and this
233
- // rule exists for the silent case.
234
- const callback = node.arguments[0]
235
- if (!callback || callback.type !== "ArrowFunctionExpression") return
236
- const param = callback.params[0]
237
- const paramName = param && param.type === "Identifier" ? param.name : "the transactional manager"
238
- walkPreservingThis(callback.body, (descendant) => {
239
- if (
240
- descendant.type === "MemberExpression"
241
- && !descendant.computed
242
- && descendant.object.type === "ThisExpression"
243
- && descendant.property.type === "Identifier"
244
- && descendant.property.name === field
245
- ) {
246
- context.report({ node: descendant, messageId: "outerManager", data: { field, param: paramName } })
247
- }
248
- })
249
- },
250
- }
251
- },
252
262
  }
253
263
 
254
264
  // -- DATA-5 (Rule 6) --------------------------------------------------------------------------------
@@ -258,195 +268,114 @@ const RELATION_DECORATORS = /^(?:ManyToOne|OneToOne|OneToMany|ManyToMany)$/
258
268
 
259
269
  /** A relation is asked for by the call site that needs it; the entity grants no relation eagerly. */
260
270
  export const noEagerRelation = {
261
- meta: {
262
- type: "problem",
263
- docs: {
264
- description: "A relation decorator carries no `eager: true` (data-access.md DATA-5, Rule 6).",
271
+ meta: {
272
+ type: "problem",
273
+ docs: {
274
+ description: "A relation decorator carries no `eager: true` (data-access.md DATA-5, Rule 6).",
275
+ },
276
+ schema: [],
277
+ messages: {
278
+ eager:
279
+ "`@{{decorator}}(..., { eager: true })` grants this relation to every caller whether it asked for it or not, so a query that wants one column now pays for the whole tree. State the relation in the `relations` the call site writes, and drop `eager` here.",
280
+ },
265
281
  },
266
- schema: [],
267
- messages: {
268
- eager:
269
- "`@{{decorator}}(..., { eager: true })` grants this relation to every caller whether it asked for it or not, so a query that wants one column now pays for the whole tree. State the relation in the `relations` the call site writes, and drop `eager` here.",
270
- },
271
- },
272
- create(context) {
273
- return {
274
- Decorator(node) {
275
- const expression = node.expression
276
- if (expression.type !== "CallExpression") return
277
- if (expression.callee.type !== "Identifier" || !RELATION_DECORATORS.test(expression.callee.name)) return
278
- for (const argument of expression.arguments) {
279
- if (argument.type !== "ObjectExpression") continue
280
- const eagerProperty = argument.properties.find(
281
- (property) =>
282
- property.type === "Property"
283
- && !property.computed
284
- && (property.key.name === "eager" || property.key.value === "eager"),
285
- )
286
- if (!eagerProperty) continue
287
- if (eagerProperty.value.type === "Literal" && eagerProperty.value.value === true) {
288
- context.report({ node, messageId: "eager", data: { decorator: expression.callee.name } })
289
- }
282
+ create(context) {
283
+ return {
284
+ Decorator(node) {
285
+ const expression = node.expression
286
+ if (expression.type !== "CallExpression") return
287
+ if (expression.callee.type !== "Identifier" || !RELATION_DECORATORS.test(expression.callee.name)) return
288
+ for (const argument of expression.arguments) {
289
+ if (argument.type !== "ObjectExpression") continue
290
+ const eagerProperty = argument.properties.find(
291
+ (property) =>
292
+ property.type === "Property"
293
+ && !property.computed
294
+ && (property.key.name === "eager" || property.key.value === "eager"),
295
+ )
296
+ if (!eagerProperty) continue
297
+ if (eagerProperty.value.type === "Literal" && eagerProperty.value.value === true) {
298
+ context.report({ node, messageId: "eager", data: { decorator: expression.callee.name } })
299
+ }
300
+ }
301
+ },
290
302
  }
291
- },
292
- }
293
- },
294
- }
295
-
296
- // -- DATA-6 (R82) ------------------------------------------------------------------------------------
297
-
298
- const HTTP_RECEIVER = /^(?:axios|http|httpService|httpClient|axiosInstance)$/
299
- const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "request"])
300
- /** A receiver named for an integration client or SDK: `stripeClient`, `this.paymentSdk`. */
301
- const EXTERNAL_RECEIVER = /(?:Client|Sdk)$/
302
-
303
- const calleeMethodName = (callee) =>
304
- callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" ? callee.property.name : null
305
-
306
- const calleeReceiverName = (callee) => {
307
- if (callee.type !== "MemberExpression") return null
308
- const object = callee.object
309
- if (object.type === "Identifier") return object.name
310
- if (object.type === "MemberExpression" && !object.computed && object.property.type === "Identifier") return object.property.name
311
- return null
312
- }
313
-
314
- /** Whether `node` is a call this law treats as reaching an external system: `fetch`, an HTTP client, or any method of a `*Client`/`*Sdk`. */
315
- const isExternalCall = (node) => {
316
- if (node.type !== "CallExpression") return false
317
- const { callee } = node
318
- if (callee.type === "Identifier" && callee.name === "fetch") return true
319
- const method = calleeMethodName(callee)
320
- const receiver = calleeReceiverName(callee)
321
- if (!method || !receiver) return false
322
- if (HTTP_RECEIVER.test(receiver) && HTTP_METHODS.has(method)) return true
323
- return EXTERNAL_RECEIVER.test(receiver)
303
+ },
324
304
  }
325
305
 
326
- /** A short label for a reported call: `stripeClient.charge`, `fetch`. */
327
- const describeCall = (node) => {
328
- const { callee } = node
329
- if (callee.type === "Identifier") return callee.name
330
- const method = calleeMethodName(callee) ?? "?"
331
- const receiver = calleeReceiverName(callee) ?? "?"
332
- return `${receiver}.${method}`
306
+ // -- R82 ---------------------------------------------------------------------------------------------
307
+
308
+ /** The platform types whose calls leave the process. */
309
+ const PLATFORM_EXTERNAL_TYPES = new Set(["HttpClient", "MessagePublisher"])
310
+
311
+ /** Every receiver of a call chain, outermost first: `this.stripe.charges.create()` gives `this.stripe.charges`, `this.stripe`, `this`. */
312
+ const receiversOf = (callee) => {
313
+ const receivers = []
314
+ let current = callee.type === "MemberExpression" ? callee.object : null
315
+ while (current) {
316
+ receivers.push(current)
317
+ if (current.type === "MemberExpression") current = current.object
318
+ else if (current.type === "CallExpression") current = current.callee.type === "MemberExpression" ? current.callee.object : null
319
+ else if (current.type === "TSNonNullExpression") current = current.expression
320
+ else current = null
321
+ }
322
+ return receivers
333
323
  }
334
324
 
335
325
  /** No transaction spans an external call: commit first, then call out (or write an outbox message inside the transaction). */
336
326
  export const noExternalCallInTransaction = {
337
- meta: {
338
- type: "problem",
339
- docs: {
340
- description: "An HTTP or SDK call does not run inside `.transaction(async (tx) => ...)`.",
341
- },
342
- schema: [],
343
- messages: {
344
- external:
345
- "`{{call}}` runs inside `.transaction(...)`. A database transaction held open for as long as an external call takes means: the call fails after the transaction already did its work and now has to be undone by hand, or the call succeeds and the transaction then rolls back, and the two systems disagree with no way back. Commit the transaction, then make the call - or write an outbox message inside the transaction and let a worker deliver it after commit.",
327
+ meta: {
328
+ type: "problem",
329
+ docs: { description: "An HTTP, SDK or publish call does not run inside `<manager>.transaction(async (tx) => ...)`." },
330
+ schema: [],
331
+ messages: {
332
+ external:
333
+ "`{{call}}` runs inside `.transaction(...)`. A database transaction held open for as long as an external call takes means: the call fails after the transaction already did its work and now has to be undone by hand, or the call succeeds and the transaction then rolls back, and the two systems disagree with no way back. Commit the transaction, then make the call - or write an outbox message inside the transaction and let a worker deliver it after commit.",
334
+ },
346
335
  },
347
- },
348
- create(context) {
349
- return {
350
- CallExpression(node) {
351
- const callee = node.callee
352
- if (callee.type !== "MemberExpression" || callee.computed) return
353
- if (callee.property.type !== "Identifier" || callee.property.name !== "transaction") return
354
- const callback = node.arguments[0]
355
- if (!callback || (callback.type !== "ArrowFunctionExpression" && callback.type !== "FunctionExpression")) return
356
- walk(callback.body, (child) => {
357
- if (isExternalCall(child)) context.report({ node: child, messageId: "external", data: { call: describeCall(child) } })
358
- })
359
- },
360
- }
361
- },
362
- }
363
-
364
- // -- DATA-7 (R83) ------------------------------------------------------------------------------------
365
-
366
- /** Files that build the connections: the platform database module and the migrate app. */
367
- const CONNECTION_BUILDER = /(?:^|\/)(?:platform\/(?:databases?|db|datasource|typeorm)|apps\/migrate)\//
368
-
369
- /** The one way a use case reaches the database: the shared `EntityManager`, named, then called directly. */
370
- export const namedEntityManagerOnly = {
371
- meta: {
372
- type: "problem",
373
- docs: {
374
- description:
375
- "The shared EntityManager is injected through a named injector and called directly; no bare `@InjectEntityManager()`, no `getRepository`, no injected `DataSource` (data-access.md DATA-7, R83).",
376
- },
377
- schema: [],
378
- messages: {
379
- unnamed:
380
- "`@InjectEntityManager()` names no connection, and this application registers more than one - so it points at whichever is default. Inject the shared EntityManager with the named injector (`InjectPrimaryEntityManager()`, `InjectAgentOsEntityManager()`, `InjectExpertAcademyEntityManager()`, or `@InjectEntityManager(<CONNECTION_TOKEN>)`) and call it directly.",
381
- getRepository:
382
- "`.getRepository(...)` binds a handle to ONE entity and hides which connection it came from. Inject the shared EntityManager with the named injector and call it directly: `manager.find(Entity, ...)`, `manager.save(Entity, ...)`, `tx.insert(Entity, ...)`.",
383
- dataSource:
384
- "`DataSource` is injected here to reach the database. Inject the shared EntityManager with the named injector and call it directly; a `DataSource` is built only by the platform database module.",
385
- },
386
- },
387
- create(context) {
388
- const filename = normalizePath(context.filename || context.getFilename())
389
- if (/\.d\.[cm]?ts$/.test(filename) || /\.(?:spec|test)\.[cm]?[jt]sx?$/.test(filename)) return {}
390
- const mayBuildConnections = CONNECTION_BUILDER.test(filename)
391
- return {
392
- Decorator(node) {
393
- const expression = node.expression
394
- if (expression.type !== "CallExpression" || expression.callee.type !== "Identifier") return
395
- if (expression.callee.name === "InjectEntityManager" && expression.arguments.length === 0) {
396
- context.report({ node, messageId: "unnamed" })
336
+ create(context) {
337
+ const hfs = hfsOf(context)
338
+ const sourceCode = context.sourceCode || context.getSourceCode()
339
+ /** Whether a value's type is declared in `integrations/**` or is the platform `HttpClient` / `MessagePublisher`. */
340
+ const isExternalReceiver = (node) =>
341
+ typeOrigins(context, node).some(
342
+ (origin) => hfs.tierOf(origin.file) === "integrations" || (PLATFORM_EXTERNAL_TYPES.has(origin.name) && hfs.slotOf(origin.file) === "be.platform"),
343
+ )
344
+ return {
345
+ CallExpression(node) {
346
+ const callback = transactionCallback(context, node)
347
+ if (!callback) return
348
+ walk(callback.body, (child) => {
349
+ if (child.type !== "CallExpression") return
350
+ const { callee } = child
351
+ const globalFetch = callee.type === "Identifier" && callee.name === "fetch" && !resolveVariable(sourceCode, callee)?.defs.length
352
+ if (globalFetch || receiversOf(callee).some(isExternalReceiver)) {
353
+ context.report({ node: child, messageId: "external", data: { call: sourceCode.getText(callee).slice(0, 60) } })
354
+ }
355
+ })
356
+ },
397
357
  }
398
- },
399
- CallExpression(node) {
400
- const callee = node.callee
401
- if (callee.type !== "MemberExpression" || callee.computed) return
402
- if (callee.property.type === "Identifier" && callee.property.name === "getRepository") {
403
- context.report({ node, messageId: "getRepository" })
404
- }
405
- },
406
- MethodDefinition(node) {
407
- if (mayBuildConnections) return
408
- for (const original of constructorParams(node)) {
409
- const param = unwrapParam(original)
410
- const byType = paramTypeName(param) === "DataSource"
411
- const byDecorator = paramDecorators(original).some((name) => name === "InjectDataSource" || name === "InjectConnection")
412
- if (byType || byDecorator) context.report({ node: param, messageId: "dataSource" })
413
- }
414
- },
415
- }
416
- },
358
+ },
417
359
  }
418
360
 
419
361
  /** The rules this law contributes to the plugin. */
420
362
  export const rules = {
421
- "must-inject-entity-manager": mustInjectEntityManager,
422
- "no-injected-repository": noInjectedRepository,
423
- "require-entity-table-name": requireEntityTableName,
424
- "no-outer-manager-in-transaction": noOuterManagerInTransaction,
425
- "no-eager-relation": noEagerRelation,
426
- "no-external-call-in-transaction": noExternalCallInTransaction,
427
- "named-entity-manager-only": namedEntityManagerOnly,
363
+ "must-inject-entity-manager": mustInjectEntityManager,
364
+ "no-injected-repository": noInjectedRepository,
365
+ "require-entity-table-name": requireEntityTableName,
366
+ "no-outer-manager-in-transaction": noOuterManagerInTransaction,
367
+ "no-eager-relation": noEagerRelation,
368
+ "no-external-call-in-transaction": noExternalCallInTransaction,
369
+ "named-entity-manager-only": namedEntityManagerOnly,
428
370
  }
429
371
 
430
- /**
431
- * The level this law asks for, as the plugin's own opinion.
432
- *
433
- * All five measured against the reference repository (`src/**`) before shipping. The original three
434
- * measured at zero debt and ship at `error`. `no-outer-manager-in-transaction` also measured zero -
435
- * every `.transaction(async (tx) => ...)` callback in the reference repository already uses only the
436
- * parameter it was handed - and ships at `error`. `no-eager-relation` also measured zero - no entity
437
- * in the reference repository declares `eager: true` - and ships at `error` as well; a repository
438
- * adopting these into a tree that is not already clean measures first and lands anything above zero
439
- * at `warn` with the count beside it.
440
- */
372
+ /** Every rule of this law ships at `error`; a repository is fixed before it adopts the plugin, never given a weaker level. */
441
373
  export const recommended = {
442
- "starci-be/must-inject-entity-manager": "error",
443
- "starci-be/no-injected-repository": "error",
444
- "starci-be/require-entity-table-name": "error",
445
- "starci-be/no-outer-manager-in-transaction": "error",
446
- "starci-be/no-eager-relation": "error",
447
- "starci-be/no-external-call-in-transaction": "error",
448
- "starci-be/named-entity-manager-only": "error",
374
+ "starci-be/must-inject-entity-manager": "error",
375
+ "starci-be/no-injected-repository": "error",
376
+ "starci-be/require-entity-table-name": "error",
377
+ "starci-be/no-outer-manager-in-transaction": "error",
378
+ "starci-be/no-eager-relation": "error",
379
+ "starci-be/no-external-call-in-transaction": "error",
380
+ "starci-be/named-entity-manager-only": "error",
449
381
  }
450
-
451
- /** Path helper shared with the other backend law modules. */
452
- export { normalizePath }