@starci/eslint-canon-be 2.1.1 → 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.
Files changed (40) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +8 -7
  3. package/comments.mjs +104 -1
  4. package/config-owner.mjs +113 -13
  5. package/connections.mjs +1 -12
  6. package/cqrs.mjs +68 -0
  7. package/data-access.mjs +100 -4
  8. package/error-handling.mjs +107 -1
  9. package/index.mjs +2 -0
  10. package/infra-owner.mjs +39 -0
  11. package/input-bounds.mjs +64 -2
  12. package/lib/config.mjs +1 -1
  13. package/lib/hfs.mjs +7 -6
  14. package/lib/persistence.mjs +29 -0
  15. package/log-safety.mjs +89 -2
  16. package/package.json +1 -1
  17. package/runtime/knowledge/hfs/canon-pins.yaml +28 -9
  18. package/runtime/knowledge/hfs/slots.yaml +193 -128
  19. package/runtime/knowledge/patterns/fe/folder.yaml +36 -36
  20. package/runtime/packages/hfs/templates/app/package-scripts/package.json +22 -0
  21. package/runtime/scripts/checks/architecture/backend.mjs +1 -1
  22. package/runtime/scripts/checks/architecture/config.mjs +31 -11
  23. package/runtime/scripts/checks/architecture/contracts.mjs +4 -4
  24. package/runtime/scripts/checks/architecture/fe-slot-allows.mjs +7 -3
  25. package/runtime/scripts/checks/architecture/framework-pinned.mjs +5 -47
  26. package/runtime/scripts/checks/architecture/frontend.mjs +6 -4
  27. package/runtime/scripts/checks/architecture/hfs.mjs +104 -66
  28. package/runtime/scripts/checks/architecture/next-data.mjs +3 -2
  29. package/runtime/scripts/checks/architecture/registration.mjs +1 -1
  30. package/runtime/scripts/checks/architecture/symbols.mjs +13 -2
  31. package/runtime/scripts/checks/architecture/test-world-files.mjs +83 -45
  32. package/runtime/scripts/checks/architecture/typescript.mjs +45 -20
  33. package/runtime/scripts/checks/typescript-programs.mjs +2 -2
  34. package/runtime/scripts/lib/hfs-path-findings.mjs +13 -2
  35. package/runtime/scripts/lib/hfs-slots.mjs +244 -61
  36. package/runtime/scripts/lib/hfs-view.mjs +9 -7
  37. package/runtime/scripts/lib/language.mjs +11 -1
  38. package/service-deps.mjs +249 -0
  39. package/runtime/packages/hfs/templates/be/package-scripts/package.json +0 -19
  40. 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) return
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
@@ -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
- /** Both start at error: no baseline exists, and the repositories' fix lanes clear the debt. */
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 a back end; hfs.json declares profile ${hfs.profile}`)
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` reads the repository's `hfs.json` beside that config file and the slot manifest this package ships in
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 repository whose `eslint.config.mjs` calls this.
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 root the linted filenames live under.
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.
@@ -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) => {