@starci/eslint-canon-be 2.1.2 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +8 -7
- package/comments.mjs +104 -1
- package/config-owner.mjs +113 -13
- package/connections.mjs +1 -12
- package/cqrs.mjs +68 -0
- package/data-access.mjs +100 -4
- package/error-handling.mjs +107 -1
- package/index.mjs +2 -0
- package/infra-owner.mjs +39 -0
- package/input-bounds.mjs +64 -2
- package/lib/config.mjs +1 -1
- package/lib/hfs.mjs +7 -6
- package/lib/persistence.mjs +29 -0
- package/log-safety.mjs +89 -2
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +28 -9
- package/runtime/knowledge/hfs/slots.yaml +193 -128
- package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +22 -0
- package/runtime/scripts/checks/architecture/config.mjs +31 -11
- package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
- package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
- package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
- package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
- package/runtime/scripts/checks/architecture/hfs.mjs +104 -66
- package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
- package/runtime/scripts/checks/architecture/registration.mjs +1 -1
- package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
- package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
- package/runtime/scripts/checks/architecture/typescript.mjs +45 -20
- package/runtime/scripts/checks/typescript-programs.mjs +2 -2
- package/runtime/scripts/lib/hfs-path-findings.mjs +13 -2
- package/runtime/scripts/lib/hfs-slots.mjs +244 -61
- package/runtime/scripts/lib/hfs-view.mjs +9 -7
- package/runtime/scripts/lib/language.mjs +11 -1
- package/service-deps.mjs +249 -0
- package/runtime/packages/hfs/templates/be/package-scripts/package.json +0 -19
- package/runtime/packages/hfs/templates/fe/package-scripts/package.json +0 -13
package/data-access.mjs
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* connection. A lookalike (`InjectFooEntityManager`) or a bare `InjectEntityManager()` is not one.
|
|
11
11
|
* - `named-entity-manager-only` - no property injection of an `EntityManager`, `DataSource` or `QueryRunner`; a
|
|
12
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
|
|
13
|
+
* `getRepository(...)` on a manager or data source; and outside those places no type reference, awaited value,
|
|
14
|
+
* or `provide:` token is a `DataSource` or `QueryRunner` (a wrapper service that exposes one is the same door), and no
|
|
15
|
+
* class receives a wrapper class that exposes one.
|
|
14
16
|
* - `no-injected-repository` - no `@InjectRepository`, no `Repository<T>` type, no `TypeOrmModule.forFeature`.
|
|
15
17
|
* - `require-entity-table-name` - `@Entity()` names its table.
|
|
16
18
|
* - `no-outer-manager-in-transaction` - inside `<manager>.transaction(async (tx) => ...)` every call uses `tx`; any other
|
|
@@ -20,16 +22,19 @@
|
|
|
20
22
|
* `integrations/**`, `HttpClient` or `MessagePublisher`, and no `fetch`.
|
|
21
23
|
*/
|
|
22
24
|
|
|
25
|
+
import ts from "typescript"
|
|
23
26
|
import { walk } from "./lib/ast.mjs"
|
|
24
27
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
25
28
|
import {
|
|
26
29
|
declaredInjectorNames,
|
|
27
30
|
inDatabaseCapability,
|
|
28
31
|
inMigrateApp, inTestBootstrap,
|
|
32
|
+
implementsMigration,
|
|
29
33
|
infraTypeOf,
|
|
30
34
|
injectionSites,
|
|
35
|
+
isMigrationFile,
|
|
31
36
|
} from "./lib/persistence.mjs"
|
|
32
|
-
import { isPackageType, typeOrigins } from "./lib/types.mjs"
|
|
37
|
+
import { isPackageType, originsOfType, typed, typeOrigins } from "./lib/types.mjs"
|
|
33
38
|
|
|
34
39
|
/** The `typeorm` repository types no class receives. */
|
|
35
40
|
const REPOSITORY_TYPES = ["Repository", "TreeRepository", "MongoRepository"]
|
|
@@ -72,6 +77,12 @@ export const namedEntityManagerOnly = {
|
|
|
72
77
|
"`{{type}}` is injected as a class property. Property injection hides the dependency from the constructor and the app module. Receive the named-injector `EntityManager` as a constructor parameter.",
|
|
73
78
|
infra:
|
|
74
79
|
"`{{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.",
|
|
80
|
+
exposed:
|
|
81
|
+
"`{{type}}` is written here outside the platform database capability, the migrate app and the test world. A wrapper service that returns, holds or hands out a connection is the same door as injecting it: the connection is opened and held only by `platform/database`, and everything else calls the shared EntityManager through its named injector.",
|
|
82
|
+
token:
|
|
83
|
+
"This provider is bound to a `{{type}}`. A token that resolves to a connection object is a second door to the database: only `platform/database`, the migrate app and the test world provide one.",
|
|
84
|
+
wrapper:
|
|
85
|
+
"`{{wrapper}}` exposes a `{{type}}` through `{{member}}`, and this class receives it. A connection wrapper is not a service dependency: inject the shared EntityManager with its named injector and call it directly.",
|
|
75
86
|
getRepository:
|
|
76
87
|
"`.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
88
|
},
|
|
@@ -79,15 +90,100 @@ export const namedEntityManagerOnly = {
|
|
|
79
90
|
create(context) {
|
|
80
91
|
const hfs = hfsOf(context)
|
|
81
92
|
const filename = context.filename || context.getFilename()
|
|
82
|
-
const mayHoldConnections = inDatabaseCapability(hfs, filename) || inMigrateApp(hfs, filename) || inTestBootstrap(hfs, filename)
|
|
93
|
+
const mayHoldConnections = inDatabaseCapability(hfs, filename) || inMigrateApp(hfs, filename) || inTestBootstrap(hfs, filename) || isMigrationFile(hfs, filename)
|
|
94
|
+
const CONNECTION_TYPES = new Set(["DataSource", "QueryRunner"])
|
|
95
|
+
/** The connection type (DataSource / QueryRunner) a value or written type is declared as, else null. */
|
|
96
|
+
const connectionTypeOf = (node) => {
|
|
97
|
+
const type = infraTypeOf(context, node)
|
|
98
|
+
return type !== null && CONNECTION_TYPES.has(type) ? type : null
|
|
99
|
+
}
|
|
100
|
+
/** The connection type of a checker type (awaited), else null. */
|
|
101
|
+
const connectionOfType = (type) => {
|
|
102
|
+
if (!type) return null
|
|
103
|
+
const { checker } = typed(context)
|
|
104
|
+
const awaited = checker.getAwaitedType(type) ?? type
|
|
105
|
+
const origin = originsOfType(checker, awaited).find((entry) => entry.module === "typeorm" && CONNECTION_TYPES.has(entry.name))
|
|
106
|
+
return origin ? origin.name : null
|
|
107
|
+
}
|
|
108
|
+
/** True when the node sits inside a class that implements typeorm's `MigrationInterface` (it receives a `QueryRunner` by contract). */
|
|
109
|
+
const insideMigrationClass = (node) => {
|
|
110
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
111
|
+
if ((current.type === "ClassDeclaration" || current.type === "ClassExpression") && implementsMigration(context, current)) return true
|
|
112
|
+
}
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
/** The annotation of the constructor parameter or class property that contains this type node, else null. */
|
|
116
|
+
const siteAnnotationOf = (node) => {
|
|
117
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
118
|
+
if (current.type !== "TSTypeAnnotation") continue
|
|
119
|
+
const owner = current.parent
|
|
120
|
+
const isProperty = owner?.type === "PropertyDefinition"
|
|
121
|
+
const isParam = owner?.type === "Identifier" && (owner.parent?.type === "TSParameterProperty"
|
|
122
|
+
|| (owner.parent?.type === "FunctionExpression" && owner.parent.parent?.type === "MethodDefinition" && owner.parent.parent.kind === "constructor"))
|
|
123
|
+
return isProperty || isParam ? current.typeAnnotation : null
|
|
124
|
+
}
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
/** The member of a repository-declared class type that hands out a connection: `{ member, type, wrapper }`, else null. */
|
|
128
|
+
const exposedConnection = (annotation) => {
|
|
129
|
+
const { checker, toTs } = typed(context)
|
|
130
|
+
const tsNode = toTs(annotation)
|
|
131
|
+
if (!tsNode) return null
|
|
132
|
+
const type = checker.getTypeFromTypeNode(tsNode)
|
|
133
|
+
const symbol = type.getSymbol()
|
|
134
|
+
const declaration = symbol && symbol.flags & ts.SymbolFlags.Class ? symbol.declarations?.[0] : null
|
|
135
|
+
if (!declaration || declaration.getSourceFile().fileName.replace(/\\/g, "/").includes("/node_modules/")) return null
|
|
136
|
+
for (const property of checker.getPropertiesOfType(type)) {
|
|
137
|
+
if (property.declarations?.some((entry) => ts.getCombinedModifierFlags(entry) & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected))) continue
|
|
138
|
+
const memberType = checker.getTypeOfSymbolAtLocation(property, tsNode)
|
|
139
|
+
for (const candidate of [memberType, ...memberType.getCallSignatures().map((signature) => signature.getReturnType())]) {
|
|
140
|
+
const found = connectionOfType(candidate)
|
|
141
|
+
if (found) return { member: property.getName(), type: found, wrapper: symbol.getName() }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
83
146
|
return {
|
|
84
147
|
...injectionSites(({ node, kind, annotation }) => {
|
|
85
148
|
if (!annotation) return
|
|
86
149
|
const type = infraTypeOf(context, annotation)
|
|
87
|
-
if (!type)
|
|
150
|
+
if (!type) {
|
|
151
|
+
const wrapper = kind === "param" && !mayHoldConnections ? exposedConnection(annotation) : null
|
|
152
|
+
if (wrapper) context.report({ node, messageId: "wrapper", data: wrapper })
|
|
153
|
+
return
|
|
154
|
+
}
|
|
88
155
|
if (kind === "property") context.report({ node, messageId: "property", data: { type } })
|
|
89
156
|
else if (type !== "EntityManager" && !mayHoldConnections) context.report({ node, messageId: "infra", data: { type } })
|
|
90
157
|
}),
|
|
158
|
+
TSTypeReference(node) {
|
|
159
|
+
if (mayHoldConnections) return
|
|
160
|
+
const type = connectionTypeOf(node)
|
|
161
|
+
if (!type || insideMigrationClass(node)) return
|
|
162
|
+
const site = siteAnnotationOf(node)
|
|
163
|
+
if (site && infraTypeOf(context, site)) return
|
|
164
|
+
context.report({ node, messageId: "exposed", data: { type } })
|
|
165
|
+
},
|
|
166
|
+
AwaitExpression(node) {
|
|
167
|
+
if (mayHoldConnections || insideMigrationClass(node)) return
|
|
168
|
+
const { checker, toTs } = typed(context)
|
|
169
|
+
const tsNode = toTs(node)
|
|
170
|
+
const type = tsNode ? connectionOfType(checker.getTypeAtLocation(tsNode)) : null
|
|
171
|
+
if (type) context.report({ node, messageId: "exposed", data: { type } })
|
|
172
|
+
},
|
|
173
|
+
Property(node) {
|
|
174
|
+
if (mayHoldConnections || node.parent?.type !== "ObjectExpression" || node.computed) return
|
|
175
|
+
if ((node.key.type === "Identifier" ? node.key.name : node.key.value) !== "provide") return
|
|
176
|
+
const { checker, toTs } = typed(context)
|
|
177
|
+
for (const sibling of node.parent.properties) {
|
|
178
|
+
if (sibling.type !== "Property" || sibling.computed || sibling.key.type !== "Identifier") continue
|
|
179
|
+
const tsNode = toTs(sibling.value)
|
|
180
|
+
if (!tsNode) continue
|
|
181
|
+
const type = checker.getTypeAtLocation(tsNode)
|
|
182
|
+
const provided = sibling.key.name === "useValue" ? type : sibling.key.name === "useFactory" ? type.getCallSignatures()[0]?.getReturnType() : null
|
|
183
|
+
const found = connectionOfType(provided)
|
|
184
|
+
if (found) context.report({ node, messageId: "token", data: { type: found } })
|
|
185
|
+
}
|
|
186
|
+
},
|
|
91
187
|
CallExpression(node) {
|
|
92
188
|
const callee = node.callee
|
|
93
189
|
if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return
|
package/error-handling.mjs
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The rules that hold HFS errors and their handling (catalog R38 `BE_ERROR_HOME`, R40 `BE_LOGGER_REQUIRED
|
|
2
|
+
* The rules that hold HFS errors and their handling (catalog R38 `BE_ERROR_HOME`, R40 `BE_LOGGER_REQUIRED`, R108
|
|
3
|
+
* `BE_ERROR_CAUSE_DROPPED`).
|
|
3
4
|
*
|
|
4
5
|
* One habit: a failure is either named where its owner lives, or it is visible. Every question is answered by TYPE and by
|
|
5
6
|
* the owner that declares the type (BE-CONVENTION 1.8, 3.1): no rule here matches a class, receiver or file by its name.
|
|
6
7
|
*
|
|
7
8
|
* - `catch-must-account` refuses a `catch` (and a promise `.catch`) that swallows. A handler passes when it rethrows,
|
|
8
9
|
* returns an outcome carrying the caught error, or calls a method on a receiver typed `Logger` from `platform/logging`.
|
|
10
|
+
* - `replacement-throw-carries-cause` (R108 `BE_ERROR_CAUSE_DROPPED`) refuses a `throw` that escapes a `catch` (or a
|
|
11
|
+
* promise `.catch` handler) and neither rethrows the caught value nor carries it: the replacement capability error
|
|
12
|
+
* takes the exact caught value as `cause` (BE-ERROR-4). A value the rule cannot see through (a parameter, a call that
|
|
13
|
+
* is handed the caught value) counts as carrying, so a kept cause is never reported.
|
|
9
14
|
* - `error-home` keeps every error class where its owner is: a class that derives from `DomainError` is declared only in
|
|
10
15
|
* `errors/<capability>.error.ts` of its own owner, and a class that derives from the built-in `Error` some other way
|
|
11
16
|
* (`AbstractException`, a framework `HttpException` subclass, a bare `extends Error`) is refused.
|
|
@@ -98,6 +103,105 @@ export const catchMustAccount = {
|
|
|
98
103
|
},
|
|
99
104
|
}
|
|
100
105
|
|
|
106
|
+
/** The statements of a catch body that escape it: nested functions, nested handlers and guarded `try` blocks are not. */
|
|
107
|
+
const escapingThrows = (body) => {
|
|
108
|
+
const found = []
|
|
109
|
+
const visit = (node) => {
|
|
110
|
+
if (!node || typeof node !== "object" || typeof node.type !== "string") return
|
|
111
|
+
if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression" || node.type === "ArrowFunctionExpression") return
|
|
112
|
+
if (node.type === "CatchClause") return
|
|
113
|
+
if (node.type === "ThrowStatement") found.push(node)
|
|
114
|
+
if (node.type === "TryStatement") {
|
|
115
|
+
// a throw of the guarded block lands in the nested handler, which is judged on its own
|
|
116
|
+
if (!node.handler) visit(node.block)
|
|
117
|
+
visit(node.finalizer)
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
for (const [key, value] of Object.entries(node)) {
|
|
121
|
+
if (key === "parent" || key === "loc" || key === "range") continue
|
|
122
|
+
if (Array.isArray(value)) value.forEach(visit)
|
|
123
|
+
else visit(value)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
body.body.forEach(visit)
|
|
127
|
+
return found
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The variable `identifier` names at its position, or null. */
|
|
131
|
+
const variableOf = (sourceCode, identifier) => {
|
|
132
|
+
for (let scope = sourceCode.getScope(identifier); scope; scope = scope.upper) {
|
|
133
|
+
const variable = scope.set.get(identifier.name)
|
|
134
|
+
if (variable) return variable
|
|
135
|
+
}
|
|
136
|
+
return null
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* True when the thrown value carries the caught one: it is the caught binding, it uses it as a value, or it is a variable
|
|
141
|
+
* whose initializer or an assignment uses it. Anything the rule cannot see through is taken as carrying, so it never
|
|
142
|
+
* reports a throw that might keep the cause.
|
|
143
|
+
*/
|
|
144
|
+
const throwCarriesCaught = (sourceCode, argument, caught) => {
|
|
145
|
+
if (carriesCaught(argument, caught)) return true
|
|
146
|
+
let value = argument
|
|
147
|
+
while (value.type === "TSAsExpression" || value.type === "TSNonNullExpression" || value.type === "TSTypeAssertion") value = value.expression
|
|
148
|
+
if (value.type === "Identifier") {
|
|
149
|
+
const variable = variableOf(sourceCode, value)
|
|
150
|
+
// an unresolved global or an import cannot carry this catch's value, but a parameter may hold anything
|
|
151
|
+
if (!variable) return false
|
|
152
|
+
if (variable.defs.some((def) => def.type === "Parameter")) return true
|
|
153
|
+
if (variable.defs.some((def) => def.node.type === "VariableDeclarator" && def.node.init && carriesCaught(def.node.init, caught))) return true
|
|
154
|
+
return variable.references.some((reference) => reference.isWrite() && reference.writeExpr && carriesCaught(reference.writeExpr, caught))
|
|
155
|
+
}
|
|
156
|
+
return false
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** A catch that replaces the caught failure passes the caught value on as the replacement's `cause` (BE-ERROR-4). */
|
|
160
|
+
export const replacementThrowCarriesCause = {
|
|
161
|
+
meta: {
|
|
162
|
+
type: "problem",
|
|
163
|
+
docs: { description: "A throw that escapes a catch rethrows the caught value or carries it (as `cause`) in the replacement." },
|
|
164
|
+
schema: [],
|
|
165
|
+
messages: {
|
|
166
|
+
dropped:
|
|
167
|
+
"This `throw` replaces the caught failure without carrying it, so the stack and the original error are lost. Pass the exact caught value as `cause` of the capability error (`new <C>Error({ code, cause: {{name}} })`) or rethrow it.",
|
|
168
|
+
unbound:
|
|
169
|
+
"This `throw` replaces a failure its `catch` never bound, so the original error is lost. Bind it (`catch (error)`) and pass it as `cause` of the capability error, or rethrow it.",
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
create(context) {
|
|
173
|
+
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
174
|
+
const check = (body, param) => {
|
|
175
|
+
const caught = param?.type === "Identifier" ? param.name : null
|
|
176
|
+
for (const statement of escapingThrows(body)) {
|
|
177
|
+
const argument = statement.argument
|
|
178
|
+
if (caught === null) {
|
|
179
|
+
context.report({ node: statement, messageId: "unbound" })
|
|
180
|
+
continue
|
|
181
|
+
}
|
|
182
|
+
if (throwCarriesCaught(sourceCode, argument, caught)) continue
|
|
183
|
+
context.report({ node: statement, messageId: "dropped", data: { name: caught } })
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
CatchClause(node) {
|
|
188
|
+
// a destructured binding names parts of the failure, not the failure itself; the rule cannot tell which part is kept
|
|
189
|
+
if (node.param && node.param.type !== "Identifier") return
|
|
190
|
+
check(node.body, node.param)
|
|
191
|
+
},
|
|
192
|
+
CallExpression(node) {
|
|
193
|
+
if (node.callee.type !== "MemberExpression" || node.callee.computed) return
|
|
194
|
+
if (node.callee.property.type !== "Identifier" || node.callee.property.name !== "catch") return
|
|
195
|
+
const handler = node.arguments[0]
|
|
196
|
+
if (!handler || (handler.type !== "ArrowFunctionExpression" && handler.type !== "FunctionExpression")) return
|
|
197
|
+
if (handler.body.type !== "BlockStatement") return
|
|
198
|
+
if (handler.params[0] && handler.params[0].type !== "Identifier") return
|
|
199
|
+
check(handler.body, handler.params[0])
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
|
|
101
205
|
/**
|
|
102
206
|
* The test world (slot `be.tests.world`) is the TEST COMPOSITION ROOT and owns no capability folder: its one error class
|
|
103
207
|
* `TestWorldError` lives in `test-world.error.ts` at the world root, which is the world's `errors/<capability>.error.ts`.
|
|
@@ -319,6 +423,7 @@ export const errorFamilyShape = {
|
|
|
319
423
|
/** The rules this law contributes to the plugin. */
|
|
320
424
|
export const rules = {
|
|
321
425
|
"catch-must-account": catchMustAccount,
|
|
426
|
+
"replacement-throw-carries-cause": replacementThrowCarriesCause,
|
|
322
427
|
"error-home": errorHome,
|
|
323
428
|
"throw-domain-error": throwDomainError,
|
|
324
429
|
"error-family-shape": errorFamilyShape,
|
|
@@ -327,6 +432,7 @@ export const rules = {
|
|
|
327
432
|
/** All start at error: HFS has no baseline, and the migration lanes clear the debt before a repository adopts them. */
|
|
328
433
|
export const recommended = {
|
|
329
434
|
"starci-be/catch-must-account": "error",
|
|
435
|
+
"starci-be/replacement-throw-carries-cause": "error",
|
|
330
436
|
"starci-be/error-home": "error",
|
|
331
437
|
"starci-be/throw-domain-error": "error",
|
|
332
438
|
"starci-be/error-family-shape": "error",
|
package/index.mjs
CHANGED
|
@@ -41,6 +41,7 @@ import { recommended as operationsRecommended, rules as operationsRules } from "
|
|
|
41
41
|
import { recommended as querySafetyRecommended, rules as querySafetyRules } from "./query-safety.mjs"
|
|
42
42
|
import { recommended as resilienceRecommended, rules as resilienceRules } from "./resilience.mjs"
|
|
43
43
|
import { recommended as schemaAuthorityRecommended, rules as schemaAuthorityRules } from "./schema-authority.mjs"
|
|
44
|
+
import { recommended as serviceDepsRecommended, rules as serviceDepsRules } from "./service-deps.mjs"
|
|
44
45
|
import { recommended as sizeBudgetRecommended, rules as sizeBudgetRules } from "./size-budget.mjs"
|
|
45
46
|
import { recommended as specNoSkipRecommended, rules as specNoSkipRules } from "./spec-no-skip.mjs"
|
|
46
47
|
import { recommended as specQualityRecommended, rules as specQualityRules } from "./spec-quality.mjs"
|
|
@@ -81,6 +82,7 @@ const CONTRIBUTIONS = [
|
|
|
81
82
|
{ law: "query-safety", rules: querySafetyRules, recommended: querySafetyRecommended },
|
|
82
83
|
{ law: "resilience", rules: resilienceRules, recommended: resilienceRecommended },
|
|
83
84
|
{ law: "schema-authority", rules: schemaAuthorityRules, recommended: schemaAuthorityRecommended },
|
|
85
|
+
{ law: "service-deps", rules: serviceDepsRules, recommended: serviceDepsRecommended },
|
|
84
86
|
{ law: "size-budget", rules: sizeBudgetRules, recommended: sizeBudgetRecommended },
|
|
85
87
|
{ law: "spec-no-skip", rules: specNoSkipRules, recommended: specNoSkipRecommended },
|
|
86
88
|
{ law: "spec-quality", rules: specQualityRules, recommended: specQualityRecommended },
|
package/infra-owner.mjs
CHANGED
|
@@ -18,9 +18,14 @@
|
|
|
18
18
|
* compose: specs stay refused and reach infrastructure through `useTestWorld(...)`.
|
|
19
19
|
*
|
|
20
20
|
* `no-framework-logger` (R40) and `no-ambient-clock` (R79) judge Nest `Logger` and `Date`; the table holds neither.
|
|
21
|
+
*
|
|
22
|
+
* `http-client-only-in-integrations` holds the port half of the same law: the `HttpClient` port of `platform/http` (found
|
|
23
|
+
* by the receiver's TYPE) is called only in an `integrations` owner, `platform/http` itself and the test world, so an
|
|
24
|
+
* identity provider's token or admin endpoint, a mailer API or any other remote is spoken to by one integration only.
|
|
21
25
|
*/
|
|
22
26
|
import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
|
|
23
27
|
import { moduleReferences } from "./lib/import-source.mjs"
|
|
28
|
+
import { isOwnedType } from "./lib/ports.mjs"
|
|
24
29
|
|
|
25
30
|
/** The owner id of a file: the last two segments of its owner root (`platform/http`), or null when no owner holds it. */
|
|
26
31
|
const ownerIdOf = (hfs, filename) => {
|
|
@@ -85,12 +90,46 @@ export const infraImportOwner = {
|
|
|
85
90
|
},
|
|
86
91
|
}
|
|
87
92
|
|
|
93
|
+
/** The `HttpClient` port of `platform/http`: the one outbound HTTP door, judged by the receiver's TYPE. */
|
|
94
|
+
const isHttpClient = (context, node) => isOwnedType(context, node, { name: "HttpClient", capability: "http", tier: "platform" })
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A provider is spoken to only by its integration (BE-INFRA-3): a call on the `HttpClient` port is made only in an
|
|
98
|
+
* `integrations` owner, in `platform/http` itself and in the test world. A domain service, a feature or another platform
|
|
99
|
+
* capability that calls an identity provider's token or admin endpoint, a mailer API or any other remote over HTTP holds a
|
|
100
|
+
* second copy of that provider's protocol, credentials and failure mapping; it calls the integration's public API instead.
|
|
101
|
+
*/
|
|
102
|
+
export const httpClientOnlyInIntegrations = {
|
|
103
|
+
meta: {
|
|
104
|
+
type: "problem",
|
|
105
|
+
docs: { description: "The `HttpClient` port of `platform/http` is called only by an `integrations` owner (and `platform/http`, the test world)." },
|
|
106
|
+
schema: [],
|
|
107
|
+
messages: {
|
|
108
|
+
foreign: "`{{call}}` calls the `HttpClient` port of `platform/http` outside an integration. A provider (identity provider, mailer, payment, another service) is spoken to only by its `integrations/<provider>` client; call that integration's public API here.",
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
create(context) {
|
|
112
|
+
const hfs = hfsOf(context)
|
|
113
|
+
const file = context.filename
|
|
114
|
+
if (hfs.tierOf(file) === "integrations" || ownerIdOf(hfs, file) === "platform/http" || inTestWorld(hfs, file)) return {}
|
|
115
|
+
return {
|
|
116
|
+
CallExpression(node) {
|
|
117
|
+
const callee = node.callee
|
|
118
|
+
if (callee.type !== "MemberExpression" || !isHttpClient(context, callee.object)) return
|
|
119
|
+
context.report({ node, messageId: "foreign", data: { call: context.sourceCode.getText(callee) } })
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
|
|
88
125
|
/** The rules this law contributes to the plugin. */
|
|
89
126
|
export const rules = {
|
|
90
127
|
"infra-import-owner": infraImportOwner,
|
|
128
|
+
"http-client-only-in-integrations": httpClientOnlyInIntegrations,
|
|
91
129
|
}
|
|
92
130
|
|
|
93
131
|
/** Every rule of this law at `error`. */
|
|
94
132
|
export const recommended = {
|
|
95
133
|
"starci-be/infra-import-owner": "error",
|
|
134
|
+
"starci-be/http-client-only-in-integrations": "error",
|
|
96
135
|
}
|
package/input-bounds.mjs
CHANGED
|
@@ -10,10 +10,15 @@
|
|
|
10
10
|
* - `no-offset-pagination` refuses offset paging: `skip`/`offset` in the options of a `find*` on an `EntityManager`,
|
|
11
11
|
* an `OFFSET` keyword in an `sql` template (or a static string given to `.query`), and `page`/`pageNumber`/`offset`
|
|
12
12
|
* properties on a transport `dto` class. Pagination is cursor-only: unstable under writes and unbounded in cost otherwise.
|
|
13
|
+
* - `auth-door-strict-rate-tier` holds the rate-limit sentence of BE-CONVENTION 1.7: a door opened for
|
|
14
|
+
* `PublicReason.AuthHandshake` or `PublicReason.SignedWebhook` (the member of the `PublicReason` enum of
|
|
15
|
+
* `domain/identity`, read from the `reason` of the opening decorator by its TYPE) also carries a decorator whose
|
|
16
|
+
* argument is `RateTier.Strict` of `platform/http-security`, on the method or on its class. A sign-in, sign-up,
|
|
17
|
+
* password-reset or callback door on the default tier is the brute-force path the strict tier exists for.
|
|
13
18
|
*/
|
|
14
19
|
import ts from "typescript"
|
|
15
20
|
import { decoratorName, keyName, staticText } from "./lib/ast.mjs"
|
|
16
|
-
import { importsFrom, presentParts, typeOf } from "./lib/declared.mjs"
|
|
21
|
+
import { importsFrom, isOwnedEnumMember, partsOf, presentParts, typeOf } from "./lib/declared.mjs"
|
|
17
22
|
import { hfsOf } from "./lib/hfs.mjs"
|
|
18
23
|
import { normalizePath } from "./lib/path.mjs"
|
|
19
24
|
import { isPackageType, typed } from "./lib/types.mjs"
|
|
@@ -171,14 +176,71 @@ export const noOffsetPagination = {
|
|
|
171
176
|
},
|
|
172
177
|
}
|
|
173
178
|
|
|
179
|
+
/** The `PublicReason` members whose doors are the brute-force and forgery paths: they owe the strict tier. */
|
|
180
|
+
const STRICT_REASONS = new Set(["AuthHandshake", "SignedWebhook"])
|
|
181
|
+
|
|
182
|
+
/** The member names of an enum-typed value, when every part of its type is a member of the owned enum; else []. */
|
|
183
|
+
const ownedMembers = (context, node, enumName, tier, capability) =>
|
|
184
|
+
isOwnedEnumMember(context, node, enumName, tier, capability) ? partsOf(typeOf(context, node)).map((part) => part.getSymbol()?.name ?? "") : []
|
|
185
|
+
|
|
186
|
+
/** The arguments of a decorator call (`@X(a, b)`), or [] for a bare `@X`. */
|
|
187
|
+
const argumentsOf = (decorator) => (decorator.expression.type === "CallExpression" ? decorator.expression.arguments : [])
|
|
188
|
+
|
|
189
|
+
/** The `reason` value of an opening decorator's options object (`@Public({ reason })`), or null. */
|
|
190
|
+
const reasonOf = (decorator) => {
|
|
191
|
+
const options = argumentsOf(decorator)[0]
|
|
192
|
+
if (options?.type !== "ObjectExpression") return null
|
|
193
|
+
const property = options.properties.find((item) => item.type === "Property" && !item.computed && keyName(item.key) === "reason")
|
|
194
|
+
return property ? property.value : null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A door opened for a handshake or a webhook is on the strict rate-limit tier. */
|
|
198
|
+
export const authDoorStrictRateTier = {
|
|
199
|
+
meta: {
|
|
200
|
+
type: "problem",
|
|
201
|
+
docs: { description: "A door opened for `PublicReason.AuthHandshake` or `PublicReason.SignedWebhook` carries `RateTier.Strict` of `platform/http-security`." },
|
|
202
|
+
schema: [],
|
|
203
|
+
messages: {
|
|
204
|
+
strict: "This door is open for `PublicReason.{{reason}}` but is not on the strict rate-limit tier. Add `@RateLimit(RateTier.Strict)` (`RateTier` of `platform/http-security`) to the method or its class: a handshake or webhook door on the default tier is an open brute-force path.",
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
create(context) {
|
|
208
|
+
const isStrict = (decorator) => argumentsOf(decorator).some((argument) => {
|
|
209
|
+
const members = ownedMembers(context, argument, "RateTier", "platform", "http-security")
|
|
210
|
+
return members.length > 0 && members.every((name) => name === "Strict")
|
|
211
|
+
})
|
|
212
|
+
const judge = (target, enclosing) => {
|
|
213
|
+
for (const decorator of target.decorators ?? []) {
|
|
214
|
+
const reason = reasonOf(decorator)
|
|
215
|
+
if (reason === null) continue
|
|
216
|
+
const opened = ownedMembers(context, reason, "PublicReason", "domain", "identity").find((name) => STRICT_REASONS.has(name))
|
|
217
|
+
if (opened === undefined) continue
|
|
218
|
+
if ([...(target.decorators ?? []), ...(enclosing?.decorators ?? [])].some(isStrict)) continue
|
|
219
|
+
context.report({ node: decorator, messageId: "strict", data: { reason: opened } })
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
ClassDeclaration(node) {
|
|
224
|
+
judge(node, null)
|
|
225
|
+
},
|
|
226
|
+
MethodDefinition(node) {
|
|
227
|
+
const owner = node.parent?.parent
|
|
228
|
+
judge(node, owner?.type === "ClassDeclaration" || owner?.type === "ClassExpression" ? owner : null)
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
|
|
174
234
|
/** The rules this law contributes to the plugin. */
|
|
175
235
|
export const rules = {
|
|
176
236
|
"input-bounded": inputBounded,
|
|
177
237
|
"no-offset-pagination": noOffsetPagination,
|
|
238
|
+
"auth-door-strict-rate-tier": authDoorStrictRateTier,
|
|
178
239
|
}
|
|
179
240
|
|
|
180
|
-
/**
|
|
241
|
+
/** All start at error: no baseline exists, and the repositories' fix lanes clear the debt. */
|
|
181
242
|
export const recommended = {
|
|
182
243
|
"starci-be/input-bounded": "error",
|
|
183
244
|
"starci-be/no-offset-pagination": "error",
|
|
245
|
+
"starci-be/auth-door-strict-rate-tier": "error",
|
|
184
246
|
}
|
package/lib/config.mjs
CHANGED
|
@@ -59,7 +59,7 @@ export const buildBeConfig = async ({ hfs, plugin, recommended }) => {
|
|
|
59
59
|
if (!hfs || typeof hfs.slotOf !== "function" || typeof hfs.repoRoot !== "string") {
|
|
60
60
|
throw new Error("starciBeConfig needs { hfs: loadHfs(import.meta.url) } - the rules read the repository's slots through it")
|
|
61
61
|
}
|
|
62
|
-
if (hfs.profile !== "be") throw new Error(`starciBeConfig lints
|
|
62
|
+
if (hfs.profile !== "be") throw new Error(`starciBeConfig lints the be side of an app (its eslint.config.mjs lives in be/); this view is ${hfs.side ? `the ${hfs.side} side` : "the app root"}`)
|
|
63
63
|
const rules = recommended ?? {}
|
|
64
64
|
if (!plugin?.rules) throw new Error("starciBeConfig needs the canon plugin")
|
|
65
65
|
if (Object.keys(rules).length === 0) throw new Error("starciBeConfig received an empty recommendation - a config with no rules is not adoption")
|
package/lib/hfs.mjs
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* The one door between the lint rules and the HFS slot manifest of the repository being linted.
|
|
3
3
|
*
|
|
4
4
|
* A consuming `eslint.config.mjs` is the one line `export default starciBeConfig({ hfs: loadHfs(import.meta.url) })`.
|
|
5
|
-
* `loadHfs`
|
|
5
|
+
* The config lives in the be/ side folder of an app; `loadHfs` finds the app-root `hfs.json` one level up and gives every file
|
|
6
|
+
* linted under that folder the view of the be side (paths relative to be/), with the slot manifest this package ships in
|
|
6
7
|
* `runtime/` (a byte copy of the runtime's `knowledge/hfs/slots.yaml`, `scripts/lib/hfs-slots.mjs` and `scripts/lib/hfs-view.mjs`, refreshed by
|
|
7
8
|
* `packages/hfs/scripts/sync-runtime.mjs`), and returns an object the factory puts in `settings.starci.hfs`.
|
|
8
9
|
*
|
|
@@ -17,7 +18,7 @@ import { declaredHfsView, openHfsView } from "../runtime/scripts/lib/hfs-view.mj
|
|
|
17
18
|
const RUNTIME = join(dirname(fileURLToPath(import.meta.url)), "..", "runtime")
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
|
-
* The HFS view of the
|
|
21
|
+
* The HFS view of the side whose `eslint.config.mjs` (in be/) calls this.
|
|
21
22
|
*
|
|
22
23
|
* @param {string} configUrl - `import.meta.url` of the repository's `eslint.config.mjs`.
|
|
23
24
|
* @returns {object} The frozen HFS view (profile, apps, connections, ruleParams, slotOf, tierOf, ownerOf).
|
|
@@ -25,13 +26,13 @@ const RUNTIME = join(dirname(fileURLToPath(import.meta.url)), "..", "runtime")
|
|
|
25
26
|
export const loadHfs = (configUrl) => openHfsView({ runtimeRoot: RUNTIME, repoRoot: dirname(fileURLToPath(configUrl)) })
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
|
-
* The HFS view of an in-memory declaration, for rule tests: no file is read except the shipped manifest.
|
|
29
|
+
* The HFS view of the be side of an in-memory app declaration, for rule tests: no file is read except the shipped manifest.
|
|
29
30
|
*
|
|
30
|
-
* @param {object} declaration - An `hfs.json` object.
|
|
31
|
-
* @param {string} repoRoot - The absolute
|
|
31
|
+
* @param {object} declaration - An app `hfs.json` object.
|
|
32
|
+
* @param {string} repoRoot - The absolute be side folder the linted filenames live under.
|
|
32
33
|
* @returns {object} The frozen HFS view.
|
|
33
34
|
*/
|
|
34
|
-
export const hfsFromDeclaration = (declaration, repoRoot) => declaredHfsView({ runtimeRoot: RUNTIME, declaration, repoRoot })
|
|
35
|
+
export const hfsFromDeclaration = (declaration, repoRoot) => declaredHfsView({ runtimeRoot: RUNTIME, declaration, repoRoot, side: "be" })
|
|
35
36
|
|
|
36
37
|
/**
|
|
37
38
|
* The HFS view a rule runs under; a rule that needs it and gets none refuses to guess.
|
package/lib/persistence.mjs
CHANGED
|
@@ -113,6 +113,24 @@ export const connectionFileOf = (hfs, file, role) => {
|
|
|
113
113
|
return connectionsOf(hfs).find((connection) => connection.name === name) ?? null
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/** The platform capabilities that own a `persistence/` and so may hold an `EntityManager`. */
|
|
117
|
+
const PERSISTENCE_CAPABILITIES = ["database", "inbox", "outbox", "lease"]
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Whether a class in this file may receive an `EntityManager`: an application handler, a domain service, a platform persistence capability.
|
|
121
|
+
*
|
|
122
|
+
* @param {object} hfs - The HFS view of the repository.
|
|
123
|
+
* @param {string} file - A file path.
|
|
124
|
+
* @returns {boolean} True for the EntityManager-using service roles.
|
|
125
|
+
*/
|
|
126
|
+
export const mayHoldEntityManager = (hfs, file) => {
|
|
127
|
+
const found = hfs.classify(file)
|
|
128
|
+
const base = baseNameOf(file)
|
|
129
|
+
if (found.slot === "be.feature.application") return base.endsWith(".handler.ts")
|
|
130
|
+
if (found.slot === "be.domain") return base.endsWith(".service.ts")
|
|
131
|
+
return found.slot === "be.platform" && PERSISTENCE_CAPABILITIES.includes(found.bindings?.capability)
|
|
132
|
+
}
|
|
133
|
+
|
|
116
134
|
/**
|
|
117
135
|
* The `typeorm` infrastructure type of a node (`EntityManager`, `DataSource`, `QueryRunner`), by type and not by name.
|
|
118
136
|
*
|
|
@@ -125,6 +143,17 @@ export const infraTypeOf = (context, node) => {
|
|
|
125
143
|
return origin ? origin.name : null
|
|
126
144
|
}
|
|
127
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Whether a class is a TypeORM migration: it implements the `MigrationInterface` typeorm declares. A migration receives a
|
|
148
|
+
* `QueryRunner` by contract, wherever the file sits (a fixture migration is one too).
|
|
149
|
+
*
|
|
150
|
+
* @param {object} context - The ESLint rule context.
|
|
151
|
+
* @param {object} classNode - A ClassDeclaration or ClassExpression.
|
|
152
|
+
* @returns {boolean} True when the class implements typeorm's `MigrationInterface`.
|
|
153
|
+
*/
|
|
154
|
+
export const implementsMigration = (context, classNode) =>
|
|
155
|
+
(classNode.implements ?? []).some((entry) => typeOrigins(context, entry).some((origin) => origin.module === "typeorm" && origin.name === "MigrationInterface"))
|
|
156
|
+
|
|
128
157
|
/** The decorator names written on a node: `@Foo()` and `@Foo` both give `Foo`. */
|
|
129
158
|
const decoratorNamesOf = (node) =>
|
|
130
159
|
(node.decorators ?? []).flatMap((decorator) => {
|