@starci/eslint-canon-be 1.8.1 → 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 -9
- 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 +270 -155
- 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/config-owner.mjs
CHANGED
|
@@ -2,55 +2,124 @@
|
|
|
2
2
|
* The rules that hold HFS configuration and secrets (catalog R43 `BE_CONFIG_OWNER`, R44 `BE_SECRET_DEFAULT`,
|
|
3
3
|
* and the secret-comparison half of R41 `BE_DEFAULT_DENY`).
|
|
4
4
|
*
|
|
5
|
-
* - `no-direct-env-read` keeps
|
|
6
|
-
* from `<capability>.config.ts
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* - `no-direct-env-read` keeps the process environment inside the one file that declares the `EnvSource` class of
|
|
6
|
+
* `platform/config`. Everything else receives typed options from `<capability>.config.ts`. `process.env` in any form
|
|
7
|
+
* (member access, destructuring, `Reflect.get(process, "env")`, an `env` import of `node:process`), an import of
|
|
8
|
+
* `@nestjs/config` or `dotenv`, a call of `envConfig()`, and a `process.cwd()` path joined into `src` or
|
|
9
|
+
* `.starcistacks` are refused everywhere else.
|
|
10
|
+
* - `no-secret-default` refuses a default for a value typed `Secret` or `Url` by `platform/config`: a default argument
|
|
11
|
+
* to an `EnvSource` reader, a `??`/`||` fallback on such a value, and a string literal default (`""`, `"localhost"`,
|
|
12
|
+
* `"127.0.0.1"`, `"0.0.0.0"`, `http(s)://...`) given to any reader that returns one. The TYPE decides; no key name is
|
|
13
|
+
* matched. A missing value must stop the boot, naming the key.
|
|
12
14
|
* - `secret-compare-timing-safe` refuses `===` and `!==` on a secret-named identifier. A secret compares with
|
|
13
15
|
* `timingSafeEqual`, because the length of the matching prefix is a measurable side channel.
|
|
14
16
|
*
|
|
15
|
-
*
|
|
16
|
-
* They are not outside the comparison rule.
|
|
17
|
+
* No test lane is exempt: a spec builds its `EnvSource` from a literal record and never touches the process environment.
|
|
17
18
|
*/
|
|
18
19
|
import { keyName, staticText, walk, wordsOf } from "./lib/ast.mjs"
|
|
19
|
-
import {
|
|
20
|
+
import { isOwnedBy, originsOf } from "./lib/declared.mjs"
|
|
21
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
22
|
+
import { isDeclarationFile, normalizePath } from "./lib/path.mjs"
|
|
20
23
|
|
|
21
|
-
const
|
|
24
|
+
const PROCESS_MODULES = new Set(["process", "node:process"])
|
|
22
25
|
|
|
23
|
-
/** `
|
|
24
|
-
const
|
|
26
|
+
/** `@nestjs/config`, `dotenv` and their subpaths. */
|
|
27
|
+
const isConfigPackage = (source) => /^(?:@nestjs\/config|dotenv)(?:\/|$)/.test(source)
|
|
28
|
+
|
|
29
|
+
/** `globalThis.process` and `global.process`. */
|
|
30
|
+
const isGlobalProcess = (node) =>
|
|
25
31
|
node.type === "MemberExpression" &&
|
|
32
|
+
!node.computed &&
|
|
33
|
+
keyName(node.property) === "process" &&
|
|
26
34
|
node.object.type === "Identifier" &&
|
|
27
|
-
node.object.name === "
|
|
28
|
-
|
|
35
|
+
(node.object.name === "globalThis" || node.object.name === "global")
|
|
36
|
+
|
|
37
|
+
/** True when the property of a member expression spells `env`. */
|
|
38
|
+
const spellsEnv = (node) => (node.computed ? staticText(node.property) === "env" : keyName(node.property) === "env")
|
|
29
39
|
|
|
30
|
-
/** Only the
|
|
40
|
+
/** Only the file that declares `EnvSource` reads the process environment. */
|
|
31
41
|
export const noDirectEnvRead = {
|
|
32
42
|
meta: {
|
|
33
43
|
type: "problem",
|
|
34
|
-
docs: { description: "
|
|
44
|
+
docs: { description: "The process environment is read only by the `EnvSource` class of `platform/config`." },
|
|
35
45
|
schema: [],
|
|
36
46
|
messages: {
|
|
37
|
-
env: "
|
|
38
|
-
|
|
47
|
+
env: "The process environment is read outside the `EnvSource` of `platform/config`. Add the key to the capability's `<capability>.config.ts` (`parse<C>Config(env: EnvSource)`) and receive the value through its `<capability>.options.ts`.",
|
|
48
|
+
package: "`{{source}}` is a second config path. Configuration is parsed once in `main.ts` by `platform/config` typed readers and reaches a module through `register(options)`.",
|
|
49
|
+
envConfig: "`envConfig()` re-reads the environment per call site. Receive the value through the capability's options, read with `Inject<C>Options()`.",
|
|
50
|
+
cwdPath: "A path built from `process.cwd()` and `{{segment}}` depends on where the process was started. Resolve it from a configured root.",
|
|
39
51
|
},
|
|
40
52
|
},
|
|
41
53
|
create(context) {
|
|
42
54
|
const filename = normalizePath(context.filename || context.getFilename())
|
|
43
|
-
if (isDeclarationFile(filename)
|
|
55
|
+
if (isDeclarationFile(filename)) return {}
|
|
56
|
+
const hfs = hfsOf(context)
|
|
57
|
+
const inConfigOwner = isOwnedBy(hfs, filename, "platform", "config")
|
|
58
|
+
let declaresEnvSource = false
|
|
59
|
+
const processNames = new Set(["process"])
|
|
60
|
+
const isLocal = (identifier) => {
|
|
61
|
+
for (let scope = context.sourceCode.getScope(identifier); scope; scope = scope.upper) {
|
|
62
|
+
const variable = scope.set.get(identifier.name)
|
|
63
|
+
if (variable) return variable.defs.length > 0 && !variable.defs.every((definition) => definition.type === "ImportBinding")
|
|
64
|
+
}
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
const isProcess = (node) => (node.type === "Identifier" && processNames.has(node.name) && !isLocal(node)) || isGlobalProcess(node)
|
|
68
|
+
const isEnvSourceFile = () => inConfigOwner && declaresEnvSource
|
|
69
|
+
const reportEnv = (node) => {
|
|
70
|
+
if (!isEnvSourceFile()) context.report({ node, messageId: "env" })
|
|
71
|
+
}
|
|
72
|
+
const reportSource = (node, source) => {
|
|
73
|
+
if (isConfigPackage(source)) context.report({ node, messageId: "package", data: { source } })
|
|
74
|
+
}
|
|
44
75
|
return {
|
|
76
|
+
Program(program) {
|
|
77
|
+
declaresEnvSource = program.body.some((statement) => {
|
|
78
|
+
const declaration = statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" ? statement.declaration : statement
|
|
79
|
+
return declaration?.type === "ClassDeclaration" && declaration.id?.name === "EnvSource"
|
|
80
|
+
})
|
|
81
|
+
},
|
|
82
|
+
ImportDeclaration(node) {
|
|
83
|
+
const source = String(node.source.value)
|
|
84
|
+
reportSource(node, source)
|
|
85
|
+
if (!PROCESS_MODULES.has(source)) return
|
|
86
|
+
for (const specifier of node.specifiers) {
|
|
87
|
+
if (specifier.type === "ImportSpecifier" && (specifier.imported.name ?? specifier.imported.value) === "env") reportEnv(specifier)
|
|
88
|
+
else if (specifier.type !== "ImportSpecifier") processNames.add(specifier.local.name)
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
ImportExpression(node) {
|
|
92
|
+
const source = staticText(node.source)
|
|
93
|
+
if (source !== null) reportSource(node, source)
|
|
94
|
+
},
|
|
45
95
|
MemberExpression(node) {
|
|
46
|
-
if (
|
|
96
|
+
if (isProcess(node.object) && spellsEnv(node)) reportEnv(node)
|
|
97
|
+
},
|
|
98
|
+
VariableDeclarator(node) {
|
|
99
|
+
if (!node.init || !isProcess(node.init) || node.id.type !== "ObjectPattern") return
|
|
100
|
+
if (node.id.properties.some((property) => property.type === "Property" && keyName(property.key) === "env")) reportEnv(node)
|
|
47
101
|
},
|
|
48
102
|
CallExpression(node) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
103
|
+
const callee = node.callee
|
|
104
|
+
if (callee.type === "Identifier" && callee.name === "envConfig") context.report({ node, messageId: "envConfig" })
|
|
105
|
+
if (callee.type === "Identifier" && callee.name === "require") {
|
|
106
|
+
const source = staticText(node.arguments[0])
|
|
107
|
+
if (source !== null) reportSource(node, source)
|
|
108
|
+
}
|
|
109
|
+
if (callee.type !== "MemberExpression") return
|
|
110
|
+
if (callee.object.type === "Identifier" && callee.object.name === "Reflect" && keyName(callee.property) === "get") {
|
|
111
|
+
if (node.arguments[0] && isProcess(node.arguments[0]) && staticText(node.arguments[1]) === "env") reportEnv(node)
|
|
112
|
+
}
|
|
113
|
+
if (["join", "resolve"].includes(keyName(callee.property) ?? "") && !isEnvSourceFile()) {
|
|
114
|
+
let cwd = false
|
|
115
|
+
walk(node, (child) => {
|
|
116
|
+
if (child.type === "CallExpression" && child.callee.type === "MemberExpression" && isProcess(child.callee.object) && keyName(child.callee.property) === "cwd") cwd = true
|
|
117
|
+
})
|
|
118
|
+
if (!cwd) return
|
|
119
|
+
for (const argument of node.arguments) {
|
|
120
|
+
const text = staticText(argument)
|
|
121
|
+
const segment = text === null ? null : text.split(/[\\/]/).find((part) => part === "src" || part === ".starcistacks")
|
|
122
|
+
if (segment) context.report({ node, messageId: "cwdPath", data: { segment } })
|
|
54
123
|
}
|
|
55
124
|
}
|
|
56
125
|
},
|
|
@@ -58,130 +127,63 @@ export const noDirectEnvRead = {
|
|
|
58
127
|
},
|
|
59
128
|
}
|
|
60
129
|
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
/** Whether a config name is a secret, or an infrastructure URL: `POSTGRES_PASSWORD`, `webhookSecret`, `redisUrl`. */
|
|
67
|
-
const configKind = (name) => {
|
|
68
|
-
const words = wordsOf(name)
|
|
69
|
-
const last = words.at(-1)
|
|
70
|
-
if (!last || NOT_A_VALUE_WORDS.has(last)) return null
|
|
71
|
-
if (SECRET_WORDS.has(last)) return "secret"
|
|
72
|
-
if (last === "key" && words.length > 1 && KEY_PREFIXES.has(words.at(-2))) return "secret"
|
|
73
|
-
if (URL_WORDS.has(last)) return "url"
|
|
74
|
-
return null
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** The name an env read spells: `process.env.X`, `env.X`, `env["X"]`, `config.get("X")`. */
|
|
78
|
-
const envReadName = (node) => {
|
|
79
|
-
if (node.type === "MemberExpression") {
|
|
80
|
-
const object = node.object
|
|
81
|
-
const isEnvObject = isProcessEnv(object) || (object.type === "Identifier" && /^env$/i.test(object.name))
|
|
82
|
-
if (!isEnvObject) return null
|
|
83
|
-
return node.computed ? staticText(node.property) : keyName(node.property)
|
|
84
|
-
}
|
|
85
|
-
if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && keyName(node.callee.property) === "get") {
|
|
86
|
-
return staticText(node.arguments[0])
|
|
87
|
-
}
|
|
88
|
-
return null
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
const isNonEmptyText = (node) => {
|
|
92
|
-
const text = staticText(node)
|
|
93
|
-
return text !== null && text.trim() !== ""
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** An UPPER_SNAKE literal is the NAME of an environment key (`const API_KEY = "API_KEY"`), not a secret value. */
|
|
97
|
-
const isKeyName = (node, name) => {
|
|
98
|
-
const text = staticText(node) ?? ""
|
|
99
|
-
if (/^[A-Z][A-Z0-9_]*$/.test(text)) return true
|
|
100
|
-
// `const MEDIA_SIGNING_SECRET_KEY = "mediaSigningSecret"`: a constant that names a lookup key holds the key's name
|
|
101
|
-
return /^[A-Z][A-Z0-9_]*_KEY$/.test(name ?? "") && /^[a-z][A-Za-z0-9]*$/.test(text)
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const hasScheme = (node) => /^[a-z][a-z0-9+.-]*:\/\//i.test(staticText(node) ?? "")
|
|
105
|
-
|
|
106
|
-
const targetName = (node) => {
|
|
107
|
-
if (node.type === "Property" || node.type === "PropertyDefinition") return keyName(node.key)
|
|
108
|
-
if (node.type === "VariableDeclarator" && node.id.type === "Identifier") return node.id.name
|
|
109
|
-
if (node.type === "AssignmentPattern" && node.left.type === "Identifier") return node.left.name
|
|
110
|
-
return null
|
|
111
|
-
}
|
|
130
|
+
/** The brand classes of `platform/config` a default must never be given to. */
|
|
131
|
+
const BRANDS = new Set(["Secret", "Url"])
|
|
132
|
+
const SECRET_READERS = new Set(["secret", "url", "host"])
|
|
133
|
+
const DEFAULT_LITERAL = /^(?:|localhost|127\.0\.0\.1|0\.0\.0\.0|https?:\/\/.*)$/i
|
|
112
134
|
|
|
113
|
-
/** No secret
|
|
135
|
+
/** No secret or URL value has a default of any kind: a missing value stops the boot. */
|
|
114
136
|
export const noSecretDefault = {
|
|
115
137
|
meta: {
|
|
116
138
|
type: "problem",
|
|
117
|
-
docs: { description: "A
|
|
139
|
+
docs: { description: "A value typed `Secret` or `Url` never has a default, argument or fallback." },
|
|
118
140
|
schema: [],
|
|
119
141
|
messages: {
|
|
120
|
-
fallback: "`{{
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
cwdPath: "A path built from `process.cwd()` and `{{segment}}` depends on where the process was started. Resolve it from a configured root.",
|
|
142
|
+
fallback: "This `{{operator}}` gives a `Secret` or `Url` a value from source. A missing secret or URL must stop the boot with an error naming the key, not start the app with a fallback.",
|
|
143
|
+
argument: "`{{method}}` is given a default. A secret, credential, key, host or URL has no default of any kind: remove the second argument so a missing key fails naming itself.",
|
|
144
|
+
literal: "A string default (`\"\"`, `\"localhost\"`, `\"127.0.0.1\"`, `\"0.0.0.0\"` or an `http(s)://` URL) is given to a reader that returns a `Secret` or `Url`. Remove it so a missing key fails naming itself.",
|
|
124
145
|
},
|
|
125
146
|
},
|
|
126
147
|
create(context) {
|
|
127
148
|
const filename = normalizePath(context.filename || context.getFilename())
|
|
128
|
-
if (isDeclarationFile(filename)
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
149
|
+
if (isDeclarationFile(filename)) return {}
|
|
150
|
+
const hfs = hfsOf(context)
|
|
151
|
+
/** True when the node's type is (or contains) `Secret` or `Url` as `platform/config` declares them. */
|
|
152
|
+
const isBrand = (node) => originsOf(context, node).some((origin) => BRANDS.has(origin.name) && isOwnedBy(hfs, origin.file, "platform", "config"))
|
|
153
|
+
const isEnvSource = (node) => originsOf(context, node).some((origin) => origin.name === "EnvSource" && isOwnedBy(hfs, origin.file, "platform", "config"))
|
|
154
|
+
const checkFallback = (node, operator, left) => {
|
|
155
|
+
if (isBrand(left)) context.report({ node, messageId: "fallback", data: { operator } })
|
|
134
156
|
}
|
|
135
157
|
return {
|
|
136
158
|
LogicalExpression(node) {
|
|
137
|
-
if (node.operator
|
|
138
|
-
const name = envReadName(node.left)
|
|
139
|
-
const kind = name ? configKind(name) : null
|
|
140
|
-
if (kind && isNonEmptyText(node.right) && (kind === "url" ? hasScheme(node.right) : !isKeyName(node.right, name))) report(node, "fallback", name, kind)
|
|
159
|
+
if (node.operator === "??" || node.operator === "||") checkFallback(node, node.operator, node.left)
|
|
141
160
|
},
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
},
|
|
145
|
-
PropertyDefinition(node) {
|
|
146
|
-
checkLiteralTarget(node, node.value)
|
|
147
|
-
},
|
|
148
|
-
VariableDeclarator(node) {
|
|
149
|
-
checkLiteralTarget(node, node.init)
|
|
150
|
-
},
|
|
151
|
-
AssignmentPattern(node) {
|
|
152
|
-
checkLiteralTarget(node, node.right)
|
|
161
|
+
AssignmentExpression(node) {
|
|
162
|
+
if (node.operator === "??=" || node.operator === "||=") checkFallback(node, node.operator, node.left)
|
|
153
163
|
},
|
|
154
164
|
CallExpression(node) {
|
|
165
|
+
if (node.arguments.length < 2) return
|
|
155
166
|
const callee = node.callee
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
const kind = name ? configKind(name) : null
|
|
161
|
-
if (kind && (kind !== "url" || hasScheme(node.arguments[0]))) report(node, "schemaDefault", name, kind)
|
|
162
|
-
return
|
|
163
|
-
}
|
|
164
|
-
}
|
|
167
|
+
const method = callee.type === "MemberExpression" && !callee.computed ? keyName(callee.property) : null
|
|
168
|
+
const onEnvSource = method !== null && isEnvSource(callee.object)
|
|
169
|
+
if (onEnvSource && (SECRET_READERS.has(method) || isBrand(node))) {
|
|
170
|
+
context.report({ node: node.arguments[1], messageId: "argument", data: { method } })
|
|
165
171
|
return
|
|
166
172
|
}
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
for (const argument of node.arguments) {
|
|
174
|
-
const text = staticText(argument)
|
|
175
|
-
const segment = text === null ? null : text.split(/[\\/]/).find((part) => part === "src" || part === ".starcistacks")
|
|
176
|
-
if (segment) context.report({ node, messageId: "cwdPath", data: { segment } })
|
|
177
|
-
}
|
|
178
|
-
}
|
|
173
|
+
if (!isBrand(node)) return
|
|
174
|
+
const literal = node.arguments.slice(1).find((argument) => {
|
|
175
|
+
const text = staticText(argument)
|
|
176
|
+
return text !== null && DEFAULT_LITERAL.test(text)
|
|
177
|
+
})
|
|
178
|
+
if (literal) context.report({ node: literal, messageId: "literal" })
|
|
179
179
|
},
|
|
180
180
|
}
|
|
181
181
|
},
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
const SECRET_COMPARE_WORDS = new Set(["secret", "password", "passwd", "passphrase", "token", "signature", "hmac", "credential", "credentials", "apikey"])
|
|
185
|
+
const KEY_PREFIXES = new Set(["api", "secret", "private", "access", "signing", "encryption", "master", "auth", "jwt", "session"])
|
|
186
|
+
const NOT_A_VALUE_WORDS = new Set(["type", "kind", "length", "count", "ttl", "name", "prefix", "header", "scheme", "field", "path", "regex", "pattern"])
|
|
185
187
|
|
|
186
188
|
/** The identifier or property a comparison operand ends in, else null. */
|
|
187
189
|
const operandName = (node) => {
|
package/connections.mjs
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold one database = one connection = one named injector (R84), and the slots that may hold an
|
|
3
|
+
* `EntityManager` (R88).
|
|
4
|
+
*
|
|
5
|
+
* - `one-connection-per-database` (R84 `BE_CONNECTION_DUPLICATE`) closes the ways a second door to a database appears:
|
|
6
|
+
* `InjectEntityManager(`, `InjectDataSource(` and `getEntityManagerToken(` are called only in the
|
|
7
|
+
* `<conn>.decorators.ts` of the platform database capability, in the migrate app and in the test database fixture; any
|
|
8
|
+
* exported injector of an `EntityManager` (its declared type is `TypedParameterDecorator<EntityManager>`, whatever it
|
|
9
|
+
* is called) is `Inject<Pascal(conn)>EntityManager` for a declared connection and lives in that connection's
|
|
10
|
+
* `<conn>.decorators.ts`; `TypeOrmModule.forRoot*` and `new DataSource(` live in the platform database capability and
|
|
11
|
+
* the migrate app; the literal connection name is written once, in `<conn>.connection.ts`; and a `<conn>.config.ts`
|
|
12
|
+
* reads only keys that start with that connection's `envPrefix`.
|
|
13
|
+
* - `em-injection-slots` (R88 `BE_TRANSPORT_SHAPE`) lets a class receive the `EntityManager` only in an application
|
|
14
|
+
* handler, a domain service and the platform capabilities that own a `persistence/` - never in transport,
|
|
15
|
+
* integrations or an app.
|
|
16
|
+
*
|
|
17
|
+
* Every question about a receiver is a question about its TypeScript type, every question about a path is a question
|
|
18
|
+
* to the HFS slot view.
|
|
19
|
+
*/
|
|
20
|
+
import ts from "typescript"
|
|
21
|
+
import { hfsOf } from "./lib/hfs.mjs"
|
|
22
|
+
import {
|
|
23
|
+
baseNameOf,
|
|
24
|
+
connectionFileOf,
|
|
25
|
+
connectionsOf,
|
|
26
|
+
inDatabaseCapability,
|
|
27
|
+
inMigrateApp, inTestBootstrap,
|
|
28
|
+
infraTypeOf,
|
|
29
|
+
injectionSites,
|
|
30
|
+
injectorNameOf,
|
|
31
|
+
} from "./lib/persistence.mjs"
|
|
32
|
+
import { isPackageType, originsOfType, typed, typeOrigins } from "./lib/types.mjs"
|
|
33
|
+
|
|
34
|
+
/** The `@nestjs/typeorm` calls that build a second door to a connection. */
|
|
35
|
+
const RAW_CONNECTION_CALLS = new Set(["InjectEntityManager", "InjectDataSource", "getEntityManagerToken"])
|
|
36
|
+
|
|
37
|
+
/** The platform capabilities that own a `persistence/` and so may hold an `EntityManager`. */
|
|
38
|
+
const PERSISTENCE_CAPABILITIES = ["database", "inbox", "outbox", "lease"]
|
|
39
|
+
|
|
40
|
+
/** The initializers that can be an injector factory; a plain value is never typed here. */
|
|
41
|
+
const FUNCTION_LIKE = new Set(["ArrowFunctionExpression", "FunctionExpression", "CallExpression"])
|
|
42
|
+
|
|
43
|
+
/** Whether a file may build connections: the platform database capability and the migrate app. */
|
|
44
|
+
const mayBuildConnections = (hfs, file) => inDatabaseCapability(hfs, file) || inMigrateApp(hfs, file)
|
|
45
|
+
|
|
46
|
+
/** `new DataSource(` also in the test world (`src/tests/world`), which owns the shared test infrastructure (owner ruling 2026-09-30). */
|
|
47
|
+
const mayConstructDataSource = (hfs, file) => mayBuildConnections(hfs, file) || inTestBootstrap(hfs, file)
|
|
48
|
+
|
|
49
|
+
/** The connection whose name a string literal spells, or undefined. */
|
|
50
|
+
const connectionNamed = (hfs, node) =>
|
|
51
|
+
node?.type === "Literal" && typeof node.value === "string" ? connectionsOf(hfs).find((connection) => connection.name === node.value) : undefined
|
|
52
|
+
|
|
53
|
+
/** Whether a symbol is the platform composition's `TypedParameterDecorator` alias. */
|
|
54
|
+
const isTypedParameterDecorator = (hfs, checker, symbol) => {
|
|
55
|
+
const target = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol
|
|
56
|
+
const declaration = target.declarations?.find(ts.isTypeAliasDeclaration)
|
|
57
|
+
if (target.name !== "TypedParameterDecorator" || !declaration) return false
|
|
58
|
+
const found = hfs.classify(declaration.getSourceFile().fileName)
|
|
59
|
+
return found.slot === "be.platform" && found.bindings?.capability === "composition"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether a type argument is typeorm's `EntityManager`. */
|
|
63
|
+
const isEntityManagerArgument = (checker, type) => originsOfType(checker, type).some((origin) => origin.module === "typeorm" && origin.name === "EntityManager")
|
|
64
|
+
|
|
65
|
+
/** Whether a written type is `TypedParameterDecorator<EntityManager>`, following the aliases written over it. */
|
|
66
|
+
const isEntityManagerDecoratorNode = (hfs, checker, node, depth) => {
|
|
67
|
+
if (!ts.isTypeReferenceNode(node) || depth > 4) return false
|
|
68
|
+
const symbol = checker.getSymbolAtLocation(node.typeName)
|
|
69
|
+
if (!symbol) return false
|
|
70
|
+
if (isTypedParameterDecorator(hfs, checker, symbol)) return Boolean(node.typeArguments?.[0]) && isEntityManagerArgument(checker, checker.getTypeFromTypeNode(node.typeArguments[0]))
|
|
71
|
+
const target = symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol
|
|
72
|
+
const declaration = target.declarations?.find(ts.isTypeAliasDeclaration)
|
|
73
|
+
return Boolean(declaration) && isEntityManagerDecoratorNode(hfs, checker, declaration.type, depth + 1)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether a type is `TypedParameterDecorator<EntityManager>`, however an alias renamed it. */
|
|
77
|
+
const isEntityManagerDecorator = (hfs, checker, type) => {
|
|
78
|
+
const alias = type?.aliasSymbol
|
|
79
|
+
if (!alias) return false
|
|
80
|
+
if (isTypedParameterDecorator(hfs, checker, alias)) return Boolean(type.aliasTypeArguments?.[0]) && isEntityManagerArgument(checker, type.aliasTypeArguments[0])
|
|
81
|
+
const declaration = alias.declarations?.find(ts.isTypeAliasDeclaration)
|
|
82
|
+
return Boolean(declaration) && isEntityManagerDecoratorNode(hfs, checker, declaration.type, 1)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether the value written at `node` is an EntityManager injector: it, or what it returns, is `TypedParameterDecorator<EntityManager>`. */
|
|
86
|
+
const isEntityManagerInjector = (context, hfs, node) => {
|
|
87
|
+
const { checker, toTs } = typed(context)
|
|
88
|
+
const tsNode = toTs(node)
|
|
89
|
+
if (!tsNode) return false
|
|
90
|
+
const type = checker.getTypeAtLocation(tsNode)
|
|
91
|
+
return [type, ...type.getCallSignatures().map((signature) => signature.getReturnType())].some((candidate) => isEntityManagerDecorator(hfs, checker, candidate))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Whether a call reaches a value declared by `pkg` under one of `names`, however it was imported. */
|
|
95
|
+
const callsPackage = (context, node, names, pkg) => typeOrigins(context, node).some((origin) => origin.module === pkg && names.has(origin.name))
|
|
96
|
+
|
|
97
|
+
/** The constant a declared connection name is exported as: `expert-academy` gives `EXPERT_ACADEMY_CONNECTION`. */
|
|
98
|
+
const constantOf = (name) => `${name.toUpperCase().replace(/-/g, "_")}_CONNECTION`
|
|
99
|
+
|
|
100
|
+
/** The string an env key argument spells, or null when it is not a plain string. */
|
|
101
|
+
const keyText = (node) => {
|
|
102
|
+
if (node?.type === "Literal" && typeof node.value === "string") return node.value
|
|
103
|
+
return node?.type === "TemplateLiteral" && node.expressions.length === 0 ? node.quasis[0].value.cooked : null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Whether a call's receiver is an `EnvSource` instance (the reader), not the class itself. */
|
|
107
|
+
const isEnvSourceReader = (context, hfs, object) => {
|
|
108
|
+
const isEnvSource = typeOrigins(context, object).some((origin) => {
|
|
109
|
+
const found = hfs.classify(origin.file)
|
|
110
|
+
return origin.name === "EnvSource" && found.slot === "be.platform" && found.bindings?.capability === "config"
|
|
111
|
+
})
|
|
112
|
+
if (!isEnvSource) return false
|
|
113
|
+
const { checker, toTs } = typed(context)
|
|
114
|
+
return checker.getTypeAtLocation(toTs(object)).getConstructSignatures().length === 0
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** One physical database is one connection, one injector, one config and one registration. */
|
|
118
|
+
export const oneConnectionPerDatabase = {
|
|
119
|
+
meta: {
|
|
120
|
+
type: "problem",
|
|
121
|
+
docs: { description: "One database = one connection = one named `Inject<Conn>EntityManager` declared once in `platform/database`." },
|
|
122
|
+
schema: [],
|
|
123
|
+
messages: {
|
|
124
|
+
rawInjector:
|
|
125
|
+
"`{{name}}` is called here. A connection is reached only through its own injector: `{{name}}` is written in `platform/database/<connection>.decorators.ts`, the migrate app and the test database fixture, and nowhere else. Inject the shared EntityManager with `Inject<Connection>EntityManager()`.",
|
|
126
|
+
injectorHome:
|
|
127
|
+
"`{{name}}` is an EntityManager injector that is not the one injector of a declared connection. The only allowed injector is `Inject<Pascal(connection)>EntityManager`, exported from `platform/database/<connection>.decorators.ts` of a connection declared in `hfs.json`. A capability- or feature-specific injector, a second injector for one database and an alias are all a second door to the same database: use the connection's own injector.",
|
|
128
|
+
registration:
|
|
129
|
+
"`{{what}}` builds a connection outside the platform database capability and the migrate app. A connection is registered once, in `platform/database`; the migrate app builds its `DataSource` from the same `<connection>.config.ts`.",
|
|
130
|
+
literal:
|
|
131
|
+
"The connection name `{{name}}` is written as a string here. It is written once, as `export const {{constant}} = \"{{name}}\"` in `platform/database/{{name}}.connection.ts`; import that constant.",
|
|
132
|
+
envKey:
|
|
133
|
+
"`{{key}}` is read in the config of connection `{{connection}}`, whose environment keys all start with `{{prefix}}_`. A key of another prefix would point this connection at another connection's database. Read `{{prefix}}_<NAME>` only, spelled as a string literal.",
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
create(context) {
|
|
137
|
+
const hfs = hfsOf(context)
|
|
138
|
+
const filename = context.filename || context.getFilename()
|
|
139
|
+
const decoratorsFile = connectionFileOf(hfs, filename, "decorators")
|
|
140
|
+
const connectionFile = connectionFileOf(hfs, filename, "connection")
|
|
141
|
+
const configFile = connectionFileOf(hfs, filename, "config")
|
|
142
|
+
const fixtureDatabase = hfs.slotOf(filename) === "be.tests.fixtures" && baseNameOf(filename) === "database.ts"
|
|
143
|
+
const mayRawInject = Boolean(decoratorsFile) || inMigrateApp(hfs, filename) || fixtureDatabase
|
|
144
|
+
const declaredNames = new Set(connectionsOf(hfs).map((connection) => injectorNameOf(connection.name)))
|
|
145
|
+
|
|
146
|
+
/** Reports each injector-typed value written at `node`, unless it is the declared injector of this file's own connection. */
|
|
147
|
+
const checkInjector = (name, node, reportNode) => {
|
|
148
|
+
if (!isEntityManagerInjector(context, hfs, node)) return
|
|
149
|
+
if (decoratorsFile && name === injectorNameOf(decoratorsFile.name)) return
|
|
150
|
+
context.report({ node: reportNode, messageId: "injectorHome", data: { name } })
|
|
151
|
+
}
|
|
152
|
+
/** Reports a connection-name literal inside the options object of a registration. */
|
|
153
|
+
const checkOptionsName = (options) => {
|
|
154
|
+
if (options?.type !== "ObjectExpression") return
|
|
155
|
+
for (const property of options.properties) {
|
|
156
|
+
if (property.type !== "Property" || property.computed || property.key.name !== "name") continue
|
|
157
|
+
const named = connectionNamed(hfs, property.value)
|
|
158
|
+
if (named) context.report({ node: property.value, messageId: "literal", data: { name: named.name, constant: constantOf(named.name) } })
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
CallExpression(node) {
|
|
163
|
+
const { callee } = node
|
|
164
|
+
if (callsPackage(context, callee, RAW_CONNECTION_CALLS, "@nestjs/typeorm")) {
|
|
165
|
+
const name = typeOrigins(context, callee).find((origin) => RAW_CONNECTION_CALLS.has(origin.name)).name
|
|
166
|
+
if (!mayRawInject) context.report({ node, messageId: "rawInjector", data: { name } })
|
|
167
|
+
for (const argument of node.arguments) {
|
|
168
|
+
const named = connectionNamed(hfs, argument)
|
|
169
|
+
if (named) context.report({ node: argument, messageId: "literal", data: { name: named.name, constant: constantOf(named.name) } })
|
|
170
|
+
}
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && ["forRoot", "forRootAsync"].includes(callee.property.name)
|
|
174
|
+
&& isPackageType(context, callee.object, "TypeOrmModule", "@nestjs/typeorm")) {
|
|
175
|
+
if (!mayBuildConnections(hfs, filename)) context.report({ node, messageId: "registration", data: { what: `TypeOrmModule.${callee.property.name}` } })
|
|
176
|
+
checkOptionsName(node.arguments[0])
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
if (configFile && callee.type === "MemberExpression" && isEnvSourceReader(context, hfs, callee.object)) {
|
|
180
|
+
const key = keyText(node.arguments[0])
|
|
181
|
+
if (key === null || !key.startsWith(`${configFile.envPrefix}_`)) {
|
|
182
|
+
context.report({ node: node.arguments[0] ?? node, messageId: "envKey", data: { key: key ?? "<not a string literal>", connection: configFile.name, prefix: configFile.envPrefix } })
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
NewExpression(node) {
|
|
187
|
+
if (!isPackageType(context, node.callee, "DataSource", "typeorm")) return
|
|
188
|
+
if (!mayConstructDataSource(hfs, filename)) context.report({ node, messageId: "registration", data: { what: "new DataSource(...)" } })
|
|
189
|
+
checkOptionsName(node.arguments[0])
|
|
190
|
+
},
|
|
191
|
+
VariableDeclarator(node) {
|
|
192
|
+
if (node.id.type !== "Identifier") return
|
|
193
|
+
const named = connectionNamed(hfs, node.init)
|
|
194
|
+
if (named && node.id.name === constantOf(named.name) && connectionFile?.name !== named.name) {
|
|
195
|
+
context.report({ node: node.init, messageId: "literal", data: { name: named.name, constant: node.id.name } })
|
|
196
|
+
}
|
|
197
|
+
if (node.init && FUNCTION_LIKE.has(node.init.type)) checkInjector(node.id.name, node.id, node.id)
|
|
198
|
+
},
|
|
199
|
+
FunctionDeclaration(node) {
|
|
200
|
+
if (node.id) checkInjector(node.id.name, node.id, node.id)
|
|
201
|
+
},
|
|
202
|
+
ExportSpecifier(node) {
|
|
203
|
+
const exported = node.exported.name ?? node.exported.value
|
|
204
|
+
// Re-exporting a declared injector under its own name is the owner's public surface; an alias is a second injector.
|
|
205
|
+
if (declaredNames.has(exported) || (!node.parent.source && node.local.name === exported)) return
|
|
206
|
+
checkInjector(exported, node.local, node.exported)
|
|
207
|
+
},
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Whether a class in this file may receive an `EntityManager`. */
|
|
213
|
+
const mayHoldEntityManager = (hfs, file) => {
|
|
214
|
+
const found = hfs.classify(file)
|
|
215
|
+
const base = baseNameOf(file)
|
|
216
|
+
if (found.slot === "be.feature.application") return base.endsWith(".handler.ts")
|
|
217
|
+
if (found.slot === "be.domain") return base.endsWith(".service.ts")
|
|
218
|
+
return found.slot === "be.platform" && PERSISTENCE_CAPABILITIES.includes(found.bindings?.capability)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** An EntityManager is injected in an application handler, a domain service or a platform persistence capability only. */
|
|
222
|
+
export const emInjectionSlots = {
|
|
223
|
+
meta: {
|
|
224
|
+
type: "problem",
|
|
225
|
+
docs: { description: "An `EntityManager` is injected only in application handlers, domain services and the platform capabilities that own a `persistence/`." },
|
|
226
|
+
schema: [],
|
|
227
|
+
messages: {
|
|
228
|
+
slot:
|
|
229
|
+
"`EntityManager` is injected in a file of slot `{{slot}}`. Only an application `*.handler.ts`, a domain `*.service.ts` and the platform database, inbox, outbox and lease capabilities reach the database. Transport dispatches a command or query, an integration calls its provider, an app composes: move the data access into a handler or a domain service.",
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
create(context) {
|
|
233
|
+
const hfs = hfsOf(context)
|
|
234
|
+
const filename = context.filename || context.getFilename()
|
|
235
|
+
if (mayHoldEntityManager(hfs, filename)) return {}
|
|
236
|
+
return injectionSites(({ node, annotation }) => {
|
|
237
|
+
if (annotation && infraTypeOf(context, annotation) === "EntityManager") {
|
|
238
|
+
context.report({ node, messageId: "slot", data: { slot: hfs.slotOf(filename) ?? "no slot" } })
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The rules this law contributes to the plugin. */
|
|
245
|
+
export const rules = {
|
|
246
|
+
"one-connection-per-database": oneConnectionPerDatabase,
|
|
247
|
+
"em-injection-slots": emInjectionSlots,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Every rule of this law ships at `error`. */
|
|
251
|
+
export const recommended = {
|
|
252
|
+
"starci-be/one-connection-per-database": "error",
|
|
253
|
+
"starci-be/em-injection-slots": "error",
|
|
254
|
+
}
|