@starci/eslint-canon-be 1.8.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +95 -5
  2. package/README.md +41 -52
  3. package/async-discipline.mjs +2 -2
  4. package/authorization.mjs +38 -88
  5. package/builders.mjs +305 -0
  6. package/comments.mjs +19 -34
  7. package/config-owner.mjs +127 -125
  8. package/connections.mjs +254 -0
  9. package/cqrs.mjs +426 -135
  10. package/data-access.mjs +320 -391
  11. package/default-deny.mjs +14 -10
  12. package/e2e-flow.mjs +208 -180
  13. package/error-handling.mjs +254 -128
  14. package/idempotency.mjs +98 -67
  15. package/index.mjs +32 -9
  16. package/infra-owner.mjs +96 -0
  17. package/injection.mjs +602 -0
  18. package/input-bounds.mjs +166 -66
  19. package/lib/config.mjs +70 -33
  20. package/lib/db-writes.mjs +58 -0
  21. package/lib/declared.mjs +154 -0
  22. package/lib/hfs.mjs +61 -0
  23. package/lib/import-source.mjs +104 -0
  24. package/lib/persistence.mjs +160 -0
  25. package/lib/ports.mjs +138 -0
  26. package/lib/transport-slots.mjs +14 -0
  27. package/lib/types.mjs +133 -0
  28. package/lib/unit-spec.mjs +33 -0
  29. package/log-safety.mjs +94 -26
  30. package/module-layering.mjs +180 -297
  31. package/module-shape.mjs +296 -59
  32. package/naming.mjs +44 -12
  33. package/observability.mjs +57 -91
  34. package/operations.mjs +201 -0
  35. package/package.json +10 -7
  36. package/query-safety.mjs +189 -223
  37. package/resilience.mjs +126 -31
  38. package/runtime/engine/plain-object.mjs +5 -0
  39. package/runtime/engine/runtime-root.mjs +32 -0
  40. package/runtime/engine/yaml.mjs +161 -0
  41. package/runtime/knowledge/hfs/slots.yaml +1111 -0
  42. package/runtime/scripts/lib/git.mjs +53 -0
  43. package/runtime/scripts/lib/glob.mjs +23 -0
  44. package/runtime/scripts/lib/hfs-allows.mjs +57 -0
  45. package/runtime/scripts/lib/hfs-slots.mjs +729 -0
  46. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  47. package/runtime/scripts/lib/language.mjs +107 -0
  48. package/runtime/scripts/lib/path-key.mjs +17 -0
  49. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  50. package/schema-authority.mjs +273 -91
  51. package/size-budget.mjs +19 -61
  52. package/spec-no-skip.mjs +84 -0
  53. package/spec-quality.mjs +76 -35
  54. package/suppression.mjs +34 -16
  55. package/temporal.mjs +101 -36
  56. package/test-world.mjs +204 -0
  57. package/testing.mjs +99 -251
  58. package/transport.mjs +433 -142
  59. package/type-safety.mjs +50 -278
  60. package/unit-spec.mjs +452 -0
  61. package/user-copy.mjs +56 -37
  62. package/lib/slots.mjs +0 -83
package/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 `process.env` inside `platform/config`. Everything else receives typed options
6
- * from `<capability>.config.ts`; a `readEnvironment()` call inside a `useFactory` is the same read in
7
- * disguise and is refused too.
8
- * - `no-secret-default` refuses a literal default for a secret, password, token, key or URL: an `env.X ?? "..."`
9
- * fallback, a property or variable of that name holding a non-empty string, a `.default("...")` on a
10
- * schema of that name, and a path built from `process.cwd()` and `src` or `.starcistacks`. A missing value
11
- * must stop the boot, naming the key.
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
- * Test lanes are outside the default and env rules: a spec arranges its own environment and fake credentials.
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 { isDeclarationFile, isTestLane, normalizePath } from "./lib/path.mjs"
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 PLATFORM_CONFIG = /\/src\/modules\/platform\/config\//
24
+ const PROCESS_MODULES = new Set(["process", "node:process"])
22
25
 
23
- /** `process.env` or `process["env"]`. */
24
- const isProcessEnv = (node) =>
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 === "process" &&
28
- (node.computed ? node.property.type === "Literal" && node.property.value === "env" : keyName(node.property) === "env")
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 platform config capability reads the process environment. */
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: "`process.env` is read only inside `platform/config`." },
44
+ docs: { description: "The process environment is read only by the `EnvSource` class of `platform/config`." },
35
45
  schema: [],
36
46
  messages: {
37
- env: "`process.env` is read outside `platform/config`. Add the key to the capability's `<capability>.config.ts` (zod) and receive the value through its `<capability>.options.ts`.",
38
- factory: "`readEnvironment()` inside a `useFactory` reads the environment per module. `main.ts` reads it once and passes options through `AppModule.register`.",
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) || isTestLane(filename) || PLATFORM_CONFIG.test(filename)) return {}
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 (isProcessEnv(node)) context.report({ node, messageId: "env" })
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
- if (node.callee.type !== "Identifier" || node.callee.name !== "readEnvironment") return
50
- for (let parent = node.parent; parent; parent = parent.parent) {
51
- if (parent.type === "Property" && keyName(parent.key) === "useFactory") {
52
- context.report({ node, messageId: "factory" })
53
- return
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
- const SECRET_WORDS = new Set(["password", "passwd", "passphrase", "secret", "token", "credential", "credentials", "apikey", "signature", "hmac"])
62
- const KEY_PREFIXES = new Set(["api", "secret", "private", "access", "signing", "encryption", "master", "auth", "jwt", "session"])
63
- const URL_WORDS = new Set(["url", "uri", "dsn", "endpoint"])
64
- const NOT_A_VALUE_WORDS = new Set(["type", "kind", "length", "count", "ttl", "name", "prefix", "header", "scheme", "field", "path", "regex", "pattern"])
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, password, token, key or URL config has a literal default; a missing value stops the boot. */
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 secret or URL config value never has a literal default." },
139
+ docs: { description: "A value typed `Secret` or `Url` never has a default, argument or fallback." },
118
140
  schema: [],
119
141
  messages: {
120
- fallback: "`{{name}}` falls back to a literal. A missing {{kind}} must stop the boot with an error naming `{{name}}`, not start the app with a value from source.",
121
- literal: "`{{name}}` holds a literal {{kind}}. A {{kind}} is supplied by configuration, never written in source.",
122
- schemaDefault: "`.default(...)` gives `{{name}}` a value in source. Remove the default so a missing {{kind}} fails validation at boot.",
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) || isTestLane(filename)) return {}
129
- const report = (node, messageId, name, kind) => context.report({ node, messageId, data: { name, kind: kind === "url" ? "URL" : "secret" } })
130
- const checkLiteralTarget = (node, value) => {
131
- const name = targetName(node)
132
- const kind = name ? configKind(name) : null
133
- if (kind && value && isNonEmptyText(value) && (kind === "url" ? hasScheme(value) : !isKeyName(value, name))) report(node, "literal", name, kind)
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 !== "??" && node.operator !== "||") return
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
- Property(node) {
143
- checkLiteralTarget(node, node.value)
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
- if (callee.type === "MemberExpression" && keyName(callee.property) === "default" && isNonEmptyText(node.arguments[0])) {
157
- for (let parent = node.parent; parent; parent = parent.parent) {
158
- if (parent.type === "Property") {
159
- const name = keyName(parent.key)
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 (callee.type === "MemberExpression" && ["join", "resolve"].includes(keyName(callee.property) ?? "")) {
168
- let cwd = false
169
- walk(node, (child) => {
170
- if (child.type === "CallExpression" && child.callee.type === "MemberExpression" && keyName(child.callee.property) === "cwd") cwd = true
171
- })
172
- if (!cwd) return
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) => {
@@ -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
+ }