@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/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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* `
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `no-
|
|
19
|
-
*
|
|
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 {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
/**
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
// --
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
const
|
|
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
|
-
/**
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
228
|
+
/** Everything inside a transaction receives the transactional manager, not one declared outside it. */
|
|
206
229
|
export const noOuterManagerInTransaction = {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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 }
|