@starci/eslint-canon-be 3.0.7 → 3.0.9

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 (171) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/README.md +13 -7
  3. package/ambient-id.mjs +90 -0
  4. package/cli.mjs +181 -0
  5. package/comments.mjs +18 -21
  6. package/config-owner.mjs +20 -4
  7. package/connections.mjs +12 -12
  8. package/cookies.mjs +106 -0
  9. package/cqrs.mjs +22 -26
  10. package/data-access.mjs +9 -28
  11. package/default-deny.mjs +2 -1
  12. package/e2e-flow.mjs +4 -4
  13. package/event-bus.mjs +237 -0
  14. package/feature-thin.mjs +237 -0
  15. package/idempotency.mjs +3 -1
  16. package/index.mjs +22 -1
  17. package/infra-owner.mjs +1 -1
  18. package/injection.mjs +67 -22
  19. package/input-bounds.mjs +2 -1
  20. package/jobs.mjs +214 -0
  21. package/lib/ast.mjs +7 -0
  22. package/lib/config.mjs +1 -1
  23. package/lib/db-writes.mjs +1 -1
  24. package/lib/doors.mjs +81 -0
  25. package/lib/edition.mjs +45 -0
  26. package/lib/entity-writes.mjs +35 -0
  27. package/lib/hfs.mjs +3 -3
  28. package/lib/path.mjs +0 -6
  29. package/lib/persistence.mjs +11 -18
  30. package/lib/ports.mjs +5 -2
  31. package/lib/specifier.mjs +27 -0
  32. package/lib/transactions.mjs +37 -0
  33. package/lib/transport-slots.mjs +8 -3
  34. package/lib/unit-spec.mjs +63 -2
  35. package/module-layering.mjs +3 -24
  36. package/module-shape.mjs +2 -2
  37. package/naming.mjs +2 -2
  38. package/package.json +5 -3
  39. package/project-graph.mjs +8 -5
  40. package/projections.mjs +117 -0
  41. package/query-safety.mjs +1 -1
  42. package/realtime.mjs +181 -0
  43. package/runtime/engine/config.mjs +494 -0
  44. package/runtime/engine/invalid-config.mjs +15 -0
  45. package/runtime/engine/orca-config.mjs +27 -0
  46. package/runtime/engine/runtime-root.mjs +5 -0
  47. package/runtime/engine/yaml.mjs +3 -3
  48. package/runtime/knowledge/hfs/canon-pins.yaml +163 -32
  49. package/runtime/knowledge/hfs/rules.yaml +2059 -0
  50. package/runtime/knowledge/hfs/slots.yaml +915 -123
  51. package/runtime/knowledge/patterns/fe/folder.yaml +133 -27
  52. package/runtime/knowledge/sonar-gate.yaml +53 -19
  53. package/runtime/modules/models/registry.yaml +591 -0
  54. package/runtime/modules/models/runtimes.yaml +701 -0
  55. package/runtime/packages/hfs/templates/app/package-scripts/package.json +3 -10
  56. package/runtime/scripts/api/git/config-get.mjs +10 -0
  57. package/runtime/scripts/api/git/lib.mjs +17 -27
  58. package/runtime/scripts/api/git/ls-files.mjs +6 -0
  59. package/runtime/scripts/api/git/recorded-lines.mjs +19 -0
  60. package/runtime/scripts/api/git/remote.mjs +5 -0
  61. package/runtime/scripts/api/git/rev-parse-query.mjs +5 -0
  62. package/runtime/scripts/{lib/hfs-allows.mjs → hfs/allows.mjs} +3 -3
  63. package/runtime/scripts/hfs/architecture/ast-walks.mjs +220 -0
  64. package/runtime/scripts/{checks → hfs}/architecture/backend.mjs +17 -24
  65. package/runtime/scripts/{checks → hfs}/architecture/background-unowned.mjs +16 -34
  66. package/runtime/scripts/{checks → hfs}/architecture/client-reaches-server.mjs +3 -1
  67. package/runtime/scripts/{checks → hfs}/architecture/clones.mjs +4 -4
  68. package/runtime/scripts/{checks → hfs}/architecture/config.mjs +11 -8
  69. package/runtime/scripts/{checks → hfs}/architecture/connection-map.mjs +21 -9
  70. package/runtime/scripts/hfs/architecture/context-coupling.mjs +88 -0
  71. package/runtime/scripts/hfs/architecture/context-map.mjs +237 -0
  72. package/runtime/scripts/hfs/architecture/context-owner.mjs +82 -0
  73. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +47 -0
  74. package/runtime/scripts/hfs/architecture/context-transaction.mjs +60 -0
  75. package/runtime/scripts/{checks → hfs}/architecture/contract-fixture-guard.mjs +5 -11
  76. package/runtime/scripts/{checks → hfs}/architecture/contracts.mjs +33 -162
  77. package/runtime/scripts/{checks → hfs}/architecture/dead-exports.mjs +58 -29
  78. package/runtime/scripts/{checks → hfs}/architecture/doc-language.mjs +1 -1
  79. package/runtime/scripts/{checks → hfs}/architecture/entrypoint.mjs +2 -1
  80. package/runtime/scripts/{checks → hfs}/architecture/fe-slot-allows.mjs +4 -4
  81. package/runtime/scripts/{checks → hfs}/architecture/feature-shape.mjs +6 -3
  82. package/runtime/scripts/{checks → hfs}/architecture/framework-pinned.mjs +1 -1
  83. package/runtime/scripts/{checks → hfs}/architecture/frontend.mjs +17 -43
  84. package/runtime/scripts/{checks → hfs}/architecture/hfs-graph.mjs +1 -1
  85. package/runtime/scripts/{checks → hfs}/architecture/hfs.mjs +27 -42
  86. package/runtime/scripts/{checks → hfs}/architecture/i18n-keys.mjs +1 -1
  87. package/runtime/scripts/{checks → hfs}/architecture/index.mjs +17 -4
  88. package/runtime/scripts/{checks → hfs}/architecture/machine-ast.mjs +44 -11
  89. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +27 -0
  90. package/runtime/scripts/{checks → hfs}/architecture/module-per-transport.mjs +30 -6
  91. package/runtime/scripts/hfs/architecture/next-data-contract.mjs +96 -0
  92. package/runtime/scripts/{checks → hfs}/architecture/next-data.mjs +40 -168
  93. package/runtime/scripts/{checks → hfs}/architecture/owners.mjs +35 -15
  94. package/runtime/scripts/{checks → hfs}/architecture/register-once.mjs +2 -1
  95. package/runtime/scripts/{checks → hfs}/architecture/registration.mjs +23 -81
  96. package/runtime/scripts/{checks → hfs}/architecture/required-files.mjs +21 -13
  97. package/runtime/scripts/{checks → hfs}/architecture/schema-owner.mjs +17 -114
  98. package/runtime/scripts/hfs/architecture/server-action.mjs +24 -0
  99. package/runtime/scripts/hfs/architecture/side-boundary.mjs +16 -0
  100. package/runtime/scripts/{checks → hfs}/architecture/source-names.mjs +27 -147
  101. package/runtime/scripts/{checks → hfs}/architecture/sql-owner.mjs +59 -18
  102. package/runtime/scripts/hfs/architecture/sql-returning.mjs +64 -0
  103. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +111 -0
  104. package/runtime/scripts/hfs/architecture/supabase-be.mjs +131 -0
  105. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +42 -0
  106. package/runtime/scripts/hfs/architecture/supabase.mjs +434 -0
  107. package/runtime/scripts/{checks → hfs}/architecture/surface.mjs +10 -4
  108. package/runtime/scripts/{checks → hfs}/architecture/test-world-files.mjs +11 -21
  109. package/runtime/scripts/{checks → hfs}/architecture/tiers.mjs +47 -5
  110. package/runtime/scripts/{checks → hfs}/architecture/transport-owner.mjs +3 -3
  111. package/runtime/scripts/{checks → hfs}/architecture/typescript.mjs +13 -27
  112. package/runtime/scripts/hfs/declaration-shape.mjs +62 -0
  113. package/runtime/scripts/hfs/declaration-slots.mjs +75 -0
  114. package/runtime/scripts/hfs/edition-slots.mjs +66 -0
  115. package/runtime/scripts/hfs/manifest-shape.mjs +247 -0
  116. package/runtime/scripts/hfs/param-names.mjs +18 -0
  117. package/runtime/scripts/{lib/hfs-path-findings.mjs → hfs/path-findings.mjs} +7 -7
  118. package/runtime/scripts/{lib → hfs}/project-graph.mjs +0 -0
  119. package/runtime/scripts/{lib → hfs}/project-rule.mjs +3 -3
  120. package/runtime/scripts/{lib → hfs}/repo-identity.mjs +9 -6
  121. package/runtime/scripts/hfs/rule-params-shape.mjs +50 -0
  122. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/fe-no-tests.mjs +6 -5
  123. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/read.mjs +1 -1
  124. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/secrets.mjs +4 -4
  125. package/runtime/scripts/{lib/hfs-rules → hfs/rules}/spec-placement.mjs +1 -1
  126. package/runtime/scripts/{lib/hfs-slots.mjs → hfs/slots.mjs} +114 -150
  127. package/runtime/scripts/hfs/test-world-slot.mjs +6 -0
  128. package/runtime/scripts/{checks → hfs}/typescript-programs.mjs +0 -5
  129. package/runtime/scripts/{lib/hfs-view.mjs → hfs/view.mjs} +6 -5
  130. package/runtime/scripts/lib/dockerfile.mjs +124 -0
  131. package/runtime/scripts/lib/git.mjs +36 -0
  132. package/runtime/scripts/lib/i18n.mjs +86 -0
  133. package/runtime/scripts/lib/json.mjs +13 -3
  134. package/runtime/scripts/lib/language.mjs +26 -7
  135. package/runtime/scripts/lib/line-count.mjs +9 -0
  136. package/runtime/scripts/lib/list.mjs +20 -0
  137. package/runtime/scripts/lib/path-key.mjs +59 -2
  138. package/runtime/scripts/lib/read-text.mjs +34 -0
  139. package/runtime/scripts/lib/regex.mjs +4 -0
  140. package/runtime/scripts/lib/secret-patterns.mjs +4 -3
  141. package/runtime/scripts/lib/sops-envelope.mjs +58 -0
  142. package/runtime/scripts/lib/stack-services.mjs +7 -31
  143. package/runtime/scripts/lib/ts-ast.mjs +35 -0
  144. package/runtime/scripts/{checks/common.mjs → lib/walk.mjs} +5 -15
  145. package/schema-authority.mjs +14 -12
  146. package/service-deps.mjs +7 -13
  147. package/service-isolation.mjs +72 -0
  148. package/size-budget.mjs +3 -2
  149. package/test-world.mjs +5 -4
  150. package/testing.mjs +27 -24
  151. package/transport.mjs +12 -9
  152. package/unit-spec.mjs +1 -1
  153. package/user-copy.mjs +5 -4
  154. package/webhooks.mjs +173 -0
  155. package/runtime/scripts/api/sops/decrypt.mjs +0 -16
  156. package/runtime/scripts/lib/recorded-lines.mjs +0 -27
  157. package/runtime/scripts/lib/test-secrets.mjs +0 -116
  158. /package/runtime/scripts/{checks → hfs}/architecture/config-unread.mjs +0 -0
  159. /package/runtime/scripts/{checks → hfs}/architecture/constructor-deps.mjs +0 -0
  160. /package/runtime/scripts/{checks → hfs}/architecture/cross-app-duplicate.mjs +0 -0
  161. /package/runtime/scripts/{checks → hfs}/architecture/default-deny.mjs +0 -0
  162. /package/runtime/scripts/{checks → hfs}/architecture/error-codes.mjs +0 -0
  163. /package/runtime/scripts/{checks → hfs}/architecture/error-masked.mjs +0 -0
  164. /package/runtime/scripts/{checks → hfs}/architecture/hooks-are-hooks.mjs +0 -0
  165. /package/runtime/scripts/{checks → hfs}/architecture/injection-token-exported.mjs +0 -0
  166. /package/runtime/scripts/{checks → hfs}/architecture/package-shape.mjs +0 -0
  167. /package/runtime/scripts/{checks → hfs}/architecture/reachability.mjs +0 -0
  168. /package/runtime/scripts/{checks → hfs}/architecture/route-files-thin.mjs +0 -0
  169. /package/runtime/scripts/{checks → hfs}/architecture/sql-tokens.mjs +0 -0
  170. /package/runtime/scripts/{checks → hfs}/architecture/symbols.mjs +0 -0
  171. /package/runtime/scripts/{checks → hfs}/architecture/unit-spec-providers.mjs +0 -0
package/cookies.mjs ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The rule that holds the cookie half of the back-end transport law (catalog R141 `BE_COOKIE_ATTRIBUTES`).
3
+ *
4
+ * No back-end door writes a cookie under the canon today (the session travels as a bearer or as the front end's own httpOnly
5
+ * cookie), so this law is built ahead of the first one: a cookie a door writes through the response of Express or Fastify
6
+ * states `httpOnly: true`, carries `secure` and has `sameSite` `lax` or `strict`, judged on the TYPE of its options (a spread
7
+ * `as const` constant keeps its literals), and a `Set-Cookie` header is never written by hand, because a hand-written header
8
+ * states none of the three. The call is recognised by where its signature is declared (the `express` or `fastify` family of
9
+ * packages), never by the name of the receiver.
10
+ */
11
+ import ts from "typescript"
12
+ import { moduleOf, typed } from "./lib/types.mjs"
13
+
14
+ /** The packages whose response types declare the cookie writers: Express with its typings, and Fastify with its cookie plugin. */
15
+ const RESPONSE_PACKAGES = new Set([
16
+ "express",
17
+ "express-serve-static-core",
18
+ "@types/express",
19
+ "@types/express-serve-static-core",
20
+ "fastify",
21
+ "@fastify/cookie",
22
+ ])
23
+
24
+ /** The response methods that write a cookie by name, value and options, and the ones that write a header by name. */
25
+ const COOKIE_WRITERS = new Set(["cookie", "setCookie"])
26
+ const HEADER_WRITERS = new Set(["setHeader", "append", "header", "set", "appendHeader"])
27
+ const SAME_SITE = new Set(["lax", "strict"])
28
+
29
+ /** The declaration a call resolves to, when it is a method of a response type of the express or fastify family; else null. */
30
+ const responseMethodOf = (checker, toTs, call) => {
31
+ const tsCall = toTs(call)
32
+ const declaration = tsCall ? checker.getResolvedSignature(tsCall)?.declaration : undefined
33
+ if (!declaration || !(ts.isMethodSignature(declaration) || ts.isMethodDeclaration(declaration)) || !declaration.name) return null
34
+ const file = String(declaration.getSourceFile().fileName).split("\\").join("/")
35
+ const pkg = moduleOf(declaration, file)
36
+ return pkg !== null && RESPONSE_PACKAGES.has(pkg) ? declaration.name.getText() : null
37
+ }
38
+
39
+ /** The present (non-nullish) members of a property's type on an options type, or [] when the property is absent or only nullish. */
40
+ const presentParts = (checker, type, name, at) => {
41
+ const symbol = type.getProperty(name)
42
+ if (!symbol) return []
43
+ const propertyType = checker.getTypeOfSymbolAtLocation(symbol, at)
44
+ const parts = propertyType.isUnion() ? propertyType.types : [propertyType]
45
+ return parts.filter((part) => !(part.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Null | ts.TypeFlags.Void)))
46
+ }
47
+
48
+ const isBooleanLiteral = (part, value) => Boolean(part.flags & ts.TypeFlags.BooleanLiteral) && part.intrinsicName === String(value)
49
+
50
+ /** The attributes a written cookie is missing, judged on the type of its options. */
51
+ const missingAttributes = (checker, type, at) => {
52
+ const missing = []
53
+ const httpOnly = presentParts(checker, type, "httpOnly", at)
54
+ if (httpOnly.length === 0 || !httpOnly.every((part) => isBooleanLiteral(part, true))) missing.push("`httpOnly: true`")
55
+ const secure = presentParts(checker, type, "secure", at)
56
+ if (secure.length === 0 || !secure.every((part) => isBooleanLiteral(part, true))) missing.push("`secure: true`")
57
+ const sameSite = presentParts(checker, type, "sameSite", at)
58
+ if (sameSite.length === 0 || !sameSite.every((part) => part.isStringLiteral() && SAME_SITE.has(part.value))) missing.push('`sameSite: "lax"` or `"strict"`')
59
+ return missing
60
+ }
61
+
62
+ /** A cookie a door writes states httpOnly, secure and sameSite, and no door writes `Set-Cookie` by hand. */
63
+ export const responseCookieAttributes = {
64
+ meta: {
65
+ type: "problem",
66
+ docs: { description: "A cookie written through an Express or Fastify response states `httpOnly: true`, `secure: true` and `sameSite` `lax` or `strict`; `Set-Cookie` is never written as a header." },
67
+ schema: [],
68
+ messages: {
69
+ attributes:
70
+ "This cookie is written without {{missing}}. A cookie left to the defaults is readable by page script, travels over plain HTTP and is sent on cross-site requests: pass one options constant, declared `as const` by the owner of the cookie, that states `httpOnly: true`, `secure: true` and `sameSite` `lax` or `strict`.",
71
+ raw: "`Set-Cookie` is written here as a header, which states none of `httpOnly`, `secure` and `sameSite`. Write the cookie through the response's `cookie(name, value, options)` with an options constant that states all three.",
72
+ },
73
+ },
74
+ create(context) {
75
+ const { checker, toTs } = typed(context)
76
+ return {
77
+ CallExpression(node) {
78
+ if (node.callee.type !== "MemberExpression") return
79
+ const method = responseMethodOf(checker, toTs, node)
80
+ if (method === null) return
81
+ if (COOKIE_WRITERS.has(method)) {
82
+ const options = node.arguments[2]
83
+ const tsOptions = options ? toTs(options) : undefined
84
+ const missing = tsOptions ? missingAttributes(checker, checker.getTypeAtLocation(tsOptions), tsOptions) : ["`httpOnly: true`", "`secure: true`", '`sameSite: "lax"` or `"strict"`']
85
+ if (missing.length > 0) context.report({ node, messageId: "attributes", data: { missing: missing.join(", ") } })
86
+ return
87
+ }
88
+ if (!HEADER_WRITERS.has(method)) return
89
+ const name = node.arguments[0]
90
+ const tsName = name ? toTs(name) : undefined
91
+ const type = tsName ? checker.getTypeAtLocation(tsName) : undefined
92
+ if (type?.isStringLiteral() && type.value.toLowerCase() === "set-cookie") context.report({ node, messageId: "raw" })
93
+ },
94
+ }
95
+ },
96
+ }
97
+
98
+ /** Every rule this module ships, by the name a config switches it on under. */
99
+ export const rules = {
100
+ "response-cookie-attributes": responseCookieAttributes,
101
+ }
102
+
103
+ /** The level a consuming repository switches this on at. */
104
+ export const recommended = {
105
+ "starci-be/response-cookie-attributes": "error",
106
+ }
package/cqrs.mjs CHANGED
@@ -118,8 +118,9 @@ export const handlerOverridesProcess = {
118
118
  /** Statements and expressions that are a decision or a repetition: none of them belongs in a handler. */
119
119
  const DECISION_NODES = new Set(["IfStatement", "SwitchStatement", "ConditionalExpression", "LogicalExpression", "ForStatement", "ForInStatement", "ForOfStatement", "WhileStatement", "DoWhileStatement", "TryStatement", "ThrowStatement"])
120
120
 
121
- /** True when a type annotation is a domain service: a class named `*Service` declared in a `*.service.ts` file. */
122
- const isServiceType = (context, annotation) => typeOrigins(context, annotation).some((origin) => origin.module === null && origin.name.endsWith("Service") && origin.file.endsWith(".service.ts"))
121
+ /** The one collaborator a handler may hold: a domain service (a class named `*Service` declared in a `*.service.ts` file) or a projection (a class declared in a `*.projection.ts` file of slot `be.projections`). */
122
+ const isServiceType = (context, hfs, annotation) => typeOrigins(context, annotation).some((origin) => origin.module === null
123
+ && ((origin.name.endsWith("Service") && origin.file.endsWith(".service.ts")) || (origin.file.endsWith(".projection.ts") && hfs.slotOf(origin.file) === "be.projections")))
123
124
 
124
125
  /** The injected members of a class: constructor parameters and decorated properties, with the type annotation of each. */
125
126
  const injectedMembers = (node) => {
@@ -140,10 +141,10 @@ const injectedMembers = (node) => {
140
141
  export const handlerIsThin = {
141
142
  meta: {
142
143
  type: "problem",
143
- docs: { description: "A CQRS handler injects only `*Service` classes (and the template's Logger) and its `process` is one `return this.<service>.<method>(...)`." },
144
+ docs: { description: "A CQRS handler injects only `*Service` classes or projections (and the template's Logger) and its `process` is one `return this.<service>.<method>(...)`." },
144
145
  schema: [],
145
146
  messages: {
146
- dependency: "`{{what}}` is injected into a handler. A handler holds services (and the Logger the `ICQRSHandler` template needs) and nothing else: an `EntityManager`, a bus, a client or any other infrastructure is the business of a `*.service.ts`, which is the one file that is unit-tested. Move the work into a service and inject that.",
147
+ dependency: "`{{what}}` is injected into a handler. A handler holds services or projections (and the Logger the `ICQRSHandler` template needs) and nothing else: an `EntityManager`, a bus, a client or any other infrastructure is the business of a `*.service.ts`, which is the one file that is unit-tested. Move the work into a service and inject that.",
147
148
  branch: "`{{what}}` is a decision or a loop in a handler. The `process` of a handler only maps its input and returns what one service method answers; every branch belongs in the service, where it is covered by the service spec.",
148
149
  body: "`process` of `{{name}}` must be the single statement `return this.<service>.<method>(<mapped input>)` (with or without `await`). Any other statement is logic that no unit spec covers: move it into the service.",
149
150
  call: "`process` of `{{name}}` must call exactly one method of an injected `*Service` and return its result: this makes {{count}} call(s), or calls something that is not an injected service.",
@@ -158,7 +159,7 @@ export const handlerIsThin = {
158
159
  const name = node.id?.name ?? "this handler"
159
160
  const services = new Set()
160
161
  for (const injected of injectedMembers(node)) {
161
- if (injected.annotation && isServiceType(context, injected.annotation)) {
162
+ if (injected.annotation && isServiceType(context, hfs, injected.annotation)) {
162
163
  if (injected.name !== null) services.add(injected.name)
163
164
  } else if (!injected.annotation || !isLoggerType(context, injected.annotation)) {
164
165
  context.report({ node: injected.report, messageId: "dependency", data: { what: sourceCode.getText(injected.param) } })
@@ -405,18 +406,9 @@ export const noUseCase = {
405
406
 
406
407
  // -- no-event-bus ------------------------------------------------------------------------------------------------
407
408
 
408
- /** The in-process event names of `@nestjs/cqrs`. */
409
- const EVENT_NAMES = new Set(["EventBus", "EventsHandler", "IEventHandler"])
410
-
411
- /** The package of the second in-process event system. */
412
- const EVENT_EMITTER_PACKAGE = "@nestjs/event-emitter"
413
-
414
- /** The Node and RxJS classes that are an in-process event bus when constructed or extended: package -> class names. */
415
- const BUS_CLASSES = { events: new Set(["EventEmitter"]), "node:events": new Set(["EventEmitter"]), rxjs: new Set(["Subject", "BehaviorSubject", "ReplaySubject", "AsyncSubject"]) }
416
-
417
- /** The bus class a constructed or extended expression is declared as (by declaration origin), else null. */
418
- const busClassOf = (context, node) => {
419
- const origin = typeOrigins(context, node).find((entry) => entry.module !== null && BUS_CLASSES[entry.module]?.has(entry.name))
409
+ /** The bus class a constructed or extended expression is declared as (by declaration origin), else null. `classes` is the `ruleParams.be.eventBus.classes` map: module specifier -> class names. */
410
+ const busClassOf = (context, node, classes) => {
411
+ const origin = typeOrigins(context, node).find((entry) => entry.module !== null && classes[entry.module]?.includes(entry.name))
420
412
  return origin ? origin.name : null
421
413
  }
422
414
 
@@ -434,20 +426,22 @@ const holdsFunctions = (checker, type) => {
434
426
  return args.length > 0 && isFunctionType(args[args.length - 1])
435
427
  }
436
428
 
437
- /** No in-process events: a side effect that must happen anyway is an outbox message. */
429
+ /** No in-process events: a side effect that must happen anyway is an event published through the bus. */
438
430
  export const noEventBus = {
439
431
  meta: {
440
432
  type: "problem",
441
433
  docs: { description: "`EventBus`, `@EventsHandler`, `IEventHandler` and `@nestjs/event-emitter` are not used." },
442
434
  schema: [],
443
435
  messages: {
444
- event: "`{{name}}` is an in-process event mechanism. Events die with the process and run outside the transaction: write an outbox message in the transaction and consume it in `transport/message/` instead.",
445
- bus: "`{{name}}` is constructed or extended here as an in-process event bus. A `*TransitionEmitter`, a Subject used as a channel or an EventEmitter subclass has the same failure as `EventBus`: listeners live in one process and run outside the transaction. Write an outbox message in the transaction and consume it in `transport/message/`; a state a caller waits for is read from the database.",
446
- registry: "`{{name}}` stores callbacks that other code registers and this class later calls: a hand-rolled listener list is an in-process event bus. Write an outbox message in the transaction and consume it in `transport/message/` instead.",
436
+ event: "`{{name}}` is an in-process event mechanism. Events die with the process and run outside the transaction: publish an event with `eventBus.publish(event, tx)` in the transaction and consume it in `transport/message/` instead.",
437
+ bus: "`{{name}}` is constructed or extended here as an in-process event bus. A `*TransitionEmitter`, a Subject used as a channel or an EventEmitter subclass has the same failure as `EventBus`: listeners live in one process and run outside the transaction. Publish an event with `eventBus.publish(event, tx)` in the transaction and consume it in `transport/message/`; a state a caller waits for is read from the database.",
438
+ registry: "`{{name}}` stores callbacks that other code registers and this class later calls: a hand-rolled listener list is an in-process event bus. Publish an event with `eventBus.publish(event, tx)` in the transaction and consume it in `transport/message/` instead.",
447
439
  },
448
440
  },
449
441
  create(context) {
450
442
  const report = (node, name) => context.report({ node, messageId: "event", data: { name } })
443
+ const eventBus = hfsOf(context).ruleParams.eventBus
444
+ const eventNames = new Set(eventBus.imports[CQRS_PACKAGE] ?? [])
451
445
  const { checker, toTs } = typed(context)
452
446
  /** Collects, per class, the functions-collection properties and the registrations made on them. */
453
447
  const checkClass = (classNode) => {
@@ -483,21 +477,23 @@ export const noEventBus = {
483
477
  ClassDeclaration(node) {
484
478
  checkClass(node)
485
479
  if (node.superClass) {
486
- const name = busClassOf(context, node.superClass)
480
+ const name = busClassOf(context, node.superClass, eventBus.classes)
487
481
  if (name) context.report({ node: node.superClass, messageId: "bus", data: { name } })
488
482
  }
489
483
  },
490
484
  ClassExpression(node) { checkClass(node) },
491
485
  NewExpression(node) {
492
- const name = busClassOf(context, node.callee)
486
+ const name = busClassOf(context, node.callee, eventBus.classes)
493
487
  if (name) context.report({ node, messageId: "bus", data: { name } })
494
488
  },
495
489
  ...moduleReferences(({ node, source, value, names }) => {
496
- if (value === EVENT_EMITTER_PACKAGE) report(source, value)
497
- else if (value === CQRS_PACKAGE) for (const name of names) if (EVENT_NAMES.has(name)) report(node, name)
490
+ const members = eventBus.imports[value]
491
+ if (members === undefined) return
492
+ if (members.length === 0) report(source, value)
493
+ else for (const name of names) if (members.includes(name)) report(node, name)
498
494
  }),
499
495
  MemberExpression(node) {
500
- if (node.computed || node.property.type !== "Identifier" || !EVENT_NAMES.has(node.property.name)) return
496
+ if (node.computed || node.property.type !== "Identifier" || !eventNames.has(node.property.name)) return
501
497
  if (node.object.type === "Identifier" && importOf(context, node.object)?.source === CQRS_PACKAGE && importOf(context, node.object).imported === "*") report(node, node.property.name)
502
498
  },
503
499
  }
package/data-access.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  * - `must-inject-entity-manager` - a constructor parameter typed `EntityManager` carries the named injector of a declared
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
- * `DataSource` or `QueryRunner` is injected only in the platform database capability and the migrate app; no
12
+ * `DataSource` or `QueryRunner` is injected only in the platform database capability and the cli app; no
13
13
  * `getRepository(...)` on a manager or data source; and outside those places no type reference, awaited value,
14
14
  * or `provide:` token is a `DataSource` or `QueryRunner` (a wrapper service that exposes one is the same door), and no
15
15
  * class receives a wrapper class that exposes one.
@@ -24,11 +24,12 @@
24
24
 
25
25
  import ts from "typescript"
26
26
  import { walk } from "./lib/ast.mjs"
27
- import { hfsOf } from "./lib/hfs.mjs"
27
+ import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
28
+ import { resolveVariable, transactionCallback } from "./lib/transactions.mjs"
28
29
  import {
29
30
  declaredInjectorNames,
30
31
  inDatabaseCapability,
31
- inMigrateApp, inTestBootstrap,
32
+ inCli,
32
33
  implementsMigration,
33
34
  infraTypeOf,
34
35
  injectionSites,
@@ -69,18 +70,18 @@ export const namedEntityManagerOnly = {
69
70
  type: "problem",
70
71
  docs: {
71
72
  description:
72
- "No property injection of an `EntityManager`, `DataSource` or `QueryRunner`; a `DataSource` or `QueryRunner` only in the platform database capability, the migrate app and the test world (`src/tests/world`); no `getRepository`.",
73
+ "No property injection of an `EntityManager`, `DataSource` or `QueryRunner`; a `DataSource` or `QueryRunner` only in the platform database capability, the cli app and the test world (`src/tests/world`); no `getRepository`.",
73
74
  },
74
75
  schema: [],
75
76
  messages: {
76
77
  property:
77
78
  "`{{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.",
78
79
  infra:
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
+ "`{{type}}` is injected here. A `DataSource` or `QueryRunner` is built and held only by the platform database capability, the cli 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
81
  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
+ "`{{type}}` is written here outside the platform database capability, the cli 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
83
  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
+ "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 cli app and the test world provide one.",
84
85
  wrapper:
85
86
  "`{{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.",
86
87
  getRepository:
@@ -90,7 +91,7 @@ export const namedEntityManagerOnly = {
90
91
  create(context) {
91
92
  const hfs = hfsOf(context)
92
93
  const filename = context.filename || context.getFilename()
93
- const mayHoldConnections = inDatabaseCapability(hfs, filename) || inMigrateApp(hfs, filename) || inTestBootstrap(hfs, filename) || isMigrationFile(hfs, filename)
94
+ const mayHoldConnections = inDatabaseCapability(hfs, filename) || inCli(hfs, filename) || inTestWorld(hfs, filename) || isMigrationFile(hfs, filename)
94
95
  const CONNECTION_TYPES = new Set(["DataSource", "QueryRunner"])
95
96
  /** The connection type (DataSource / QueryRunner) a value or written type is declared as, else null. */
96
97
  const connectionTypeOf = (node) => {
@@ -286,26 +287,6 @@ export const requireEntityTableName = {
286
287
 
287
288
  // -- transactions ----------------------------------------------------------------------------------
288
289
 
289
- /** The callback of a `.transaction(...)` call on a manager or data source, or null. */
290
- const transactionCallback = (context, node) => {
291
- const callee = node.callee
292
- if (callee.type !== "MemberExpression" || callee.computed) return null
293
- if (callee.property.type !== "Identifier" || callee.property.name !== "transaction") return null
294
- const receiver = infraTypeOf(context, callee.object)
295
- if (receiver !== "EntityManager" && receiver !== "DataSource") return null
296
- const callback = node.arguments[node.arguments.length - 1]
297
- return callback && (callback.type === "ArrowFunctionExpression" || callback.type === "FunctionExpression") ? callback : null
298
- }
299
-
300
- /** The variable an identifier resolves to, looking outward from the identifier's own scope. */
301
- const resolveVariable = (sourceCode, node) => {
302
- for (let scope = sourceCode.getScope(node); scope; scope = scope.upper) {
303
- const variable = scope.set.get(node.name)
304
- if (variable) return variable
305
- }
306
- return null
307
- }
308
-
309
290
  /** The leftmost expression of a member chain: `this.deps.em` gives the `this`. */
310
291
  const rootOf = (expression) => {
311
292
  let current = expression
package/default-deny.mjs CHANGED
@@ -13,6 +13,7 @@
13
13
  * The `APP_GUARD` registration and the app-level guard live in the composition check, not here: a rule reading
14
14
  * one file cannot see which module the app registered.
15
15
  */
16
+ import { isTransportSlot } from "./lib/transport-slots.mjs"
16
17
  import { decoratorName, keyName } from "./lib/ast.mjs"
17
18
  import { isOwnedEnumMember } from "./lib/declared.mjs"
18
19
  import { hfsOf } from "./lib/hfs.mjs"
@@ -49,7 +50,7 @@ export const noUntypedBody = {
49
50
  const filename = normalizePath(context.filename || context.getFilename())
50
51
  if (isDeclarationFile(filename)) return {}
51
52
  const slot = hfsOf(context).slotOf(filename) ?? ""
52
- const inTransport = slot.startsWith("be.transport.") || slot.startsWith("be.feature.transport.")
53
+ const inTransport = isTransportSlot(slot) || slot.startsWith("be.feature.transport.")
53
54
  return {
54
55
  Identifier(node) {
55
56
  if (node.name === "GraphQLJSON" && !/^Import.*Specifier$/.test(node.parent?.type ?? "")) {
package/e2e-flow.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
- * The rules that hold `e2e-flow.md` (catalog R48 `BE_SPEC_QUALITY`, the e2e half).
2
+ * The rules that hold the e2e half of `knowledge/patterns/be/test.yaml` (catalog R48 `BE_SPEC_QUALITY`).
3
3
  *
4
4
  * A rule earns its place by being exact: it fires on a syntactic or typed shape, never on a judgement, or it becomes
5
- * something authors learn to work around. What these four enforce is transport bypass (a bus or an actor called
5
+ * something authors learn to work around. What these four rules enforce is transport bypass (a bus or an actor called
6
6
  * directly), a sleep instead of a poll, a branch inside a step, and a testing module built inside a spec instead of
7
7
  * booted by the test world (`useTestWorld({ apps })`, `src/tests/world/`). They do not pretend to judge business meaning: whether a file is one flow, whether
8
8
  * its steps are named, and what the absence of an effect is proved by are read by a person.
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { basename } from "node:path"
15
15
  import ts from "typescript"
16
- import { hfsOf } from "./lib/hfs.mjs"
16
+ import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
17
17
  import { isPackageType, typeOrigins, typed } from "./lib/types.mjs"
18
18
 
19
19
  /** Files this law governs. A flow is a named lane, not every file that happens to touch a database. */
@@ -172,7 +172,7 @@ export const noBranchInFlowStep = {
172
172
  !call.callee.computed &&
173
173
  call.callee.property.type === "Identifier" &&
174
174
  call.callee.property.name === "waitFor" &&
175
- typeOrigins(context, call.callee.object).some((origin) => hfs.slotOf(origin.file) === "be.tests.world")
175
+ typeOrigins(context, call.callee.object).some((origin) => inTestWorld(hfs, origin.file))
176
176
 
177
177
  /** True when this node sits inside the callback of an `it(...)` or `test(...)`, and not inside a `waitFor` probe. */
178
178
  const insideStep = (node) => {
package/event-bus.mjs ADDED
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The rules that hold the event-bus and queue patterns (catalog R152 `BE_OUTBOX_WRITE_TX`, R153 `BE_EVENT_CONSUMER_SHAPE`,
3
+ * R154 `BE_QUEUE_PRODUCER_SHAPE`).
4
+ *
5
+ * The one durable path of an event is: a domain service writes the outbox row in ITS transaction (`eventBus.publish(event, tx)`), the
6
+ * relay hands the row to Kafka, a consumer in `transport/message` receives it and dispatches one command. A queue job takes the same
7
+ * path through a typed producer (`<queue>Queue.enqueueX(payload, tx)`) and BullMQ. Raw Kafka and BullMQ clients are owned by
8
+ * `platform/event-bus` and `platform/queue` (R90 `BE_INFRA_OWNER`); Nest's in-process buses are refused by `no-event-bus`.
9
+ *
10
+ * Every rule identifies what it judges by TYPE ORIGIN (the `EventBus` port declared by `platform/event-bus`, the `QueueOutbox` port of
11
+ * `platform/queue`, typeorm's `EntityManager`) and by the slot view (tiers, `be.transport.message`, `be.queues`); none matches a
12
+ * variable, class or method name written in the source, except the contract's own members `publish`, `eventName` and `eventId`.
13
+ */
14
+ import ts from "typescript"
15
+ import { walk } from "./lib/ast.mjs"
16
+ import { hfsOf, inTestWorld } from "./lib/hfs.mjs"
17
+ import { infraTypeOf } from "./lib/persistence.mjs"
18
+ import { baseName, isOwnedType, ownerNameOf } from "./lib/ports.mjs"
19
+ import { resolveVariable, transactionCallback } from "./lib/transactions.mjs"
20
+ import { typeOrigins, typed } from "./lib/types.mjs"
21
+
22
+ /** The `EventBus` port of `platform/event-bus`. */
23
+ const isEventBusType = (context, node) => isOwnedType(context, node, { name: "EventBus", capability: "event-bus", tier: "platform" })
24
+
25
+ /** The `EventConsumer` port of `platform/event-bus`. */
26
+ const isEventConsumerType = (context, node) => isOwnedType(context, node, { name: "EventConsumer", capability: "event-bus", tier: "platform" })
27
+
28
+ /** The `EventDelivery` type of `platform/event-bus`. */
29
+ const isEventDeliveryType = (context, node) => isOwnedType(context, node, { name: "EventDelivery", capability: "event-bus", tier: "platform" })
30
+
31
+ /** The `QueueOutbox` port of `platform/queue`. */
32
+ const isQueueOutboxType = (context, node) => isOwnedType(context, node, { name: "QueueOutbox", capability: "queue", tier: "platform" })
33
+
34
+ /** True for a file the rules leave alone: the owners themselves, and the test composition (the world, e2e and fixtures). */
35
+ const isExempt = (hfs, file) => {
36
+ const tier = hfs.tierOf(file)
37
+ if (tier === "e2e" || tier === "fixtures" || inTestWorld(hfs, file)) return true
38
+ return tier === "platform" && ["event-bus", "queue"].includes(ownerNameOf(hfs, file))
39
+ }
40
+
41
+ /** The index of the parameter of a signature declaration typed as typeorm's `EntityManager`, or -1. */
42
+ const transactionParameterIndex = (checker, declaration) => {
43
+ const parameters = declaration?.parameters ?? []
44
+ return parameters.findIndex((parameter) => {
45
+ const type = checker.getTypeAtLocation(parameter)
46
+ const symbol = type.getSymbol?.()
47
+ return symbol?.getName() === "EntityManager" && String(symbol.declarations?.[0]?.getSourceFile().fileName ?? "").replace(/\\/g, "/").includes("/node_modules/typeorm/")
48
+ })
49
+ }
50
+
51
+ /**
52
+ * What an expression passed as the transaction is: the transactional manager a `.transaction(async (manager) => ...)` callback
53
+ * receives, or a function parameter typed as the `EntityManager` (a helper that runs inside the caller's transaction).
54
+ * The injected shared manager (`this.entityManager`), a local alias and any other expression are not proof of a transaction.
55
+ */
56
+ const isTransactionManager = (context, argument) => {
57
+ if (argument?.type !== "Identifier") return false
58
+ const variable = resolveVariable(context.sourceCode, argument)
59
+ const definition = variable?.defs[0]
60
+ if (definition?.type !== "Parameter") return false
61
+ const owner = definition.node
62
+ const call = owner.parent
63
+ if (call?.type === "CallExpression" && transactionCallback(context, call) === owner) return true
64
+ if (owner.parent?.type === "FunctionExpression" && owner.parent.parent?.kind === "constructor") return false
65
+ if (owner.parent?.kind === "constructor") return false
66
+ return infraTypeOf(context, definition.name) === "EntityManager"
67
+ }
68
+
69
+ /** The outbox-writing call a node is: `{ kind: "publish", argument }` or `{ kind: "producer", argument }`, or null. */
70
+ const outboxWriteOf = (context, hfs, node) => {
71
+ const callee = node.callee
72
+ if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && callee.property.name === "publish" && isEventBusType(context, callee.object)) {
73
+ return { kind: "publish", argument: node.arguments[1], expected: 1 }
74
+ }
75
+ const { checker, toTs } = typed(context)
76
+ const tsNode = toTs(node)
77
+ const declaration = tsNode ? checker.getResolvedSignature(tsNode)?.declaration : undefined
78
+ if (!declaration) return null
79
+ const file = declaration.getSourceFile().fileName
80
+ if (hfs.tierOf(file) !== "queues") return null
81
+ const index = transactionParameterIndex(checker, declaration)
82
+ return index < 0 ? null : { kind: "producer", argument: node.arguments[index], expected: index }
83
+ }
84
+
85
+ /** `eventBus.publish(event, tx)` and a queue producer's `enqueueX(payload, tx)` are called only from `modules/domain`, inside its transaction. */
86
+ export const outboxWriteTx = {
87
+ meta: {
88
+ type: "problem",
89
+ docs: { description: "The outbox is written only by a domain service, with the transaction manager of its own transaction." },
90
+ schema: [],
91
+ messages: {
92
+ foreignTier: "`{{call}}` writes the outbox outside `modules/domain`. An event or a queue job is a consequence of a domain change: the domain service that makes the change publishes it inside the same transaction. A handler, a consumer or a job asks a domain service to act, and an integration or a platform capability never publishes.",
93
+ noTransaction: "`{{call}}` does not pass the transaction manager of its own transaction. The outbox row must commit or roll back with the domain change: call it inside `this.entityManager.transaction(async (manager) => { ... })` and pass that `manager` (or a function parameter typed `EntityManager` that the transaction hands down). The injected shared manager, an alias or an outer manager is not the transaction.",
94
+ },
95
+ },
96
+ create(context) {
97
+ const hfs = hfsOf(context)
98
+ if (isExempt(hfs, context.filename)) return {}
99
+ const inDomain = hfs.tierOf(context.filename) === "domain"
100
+ return {
101
+ CallExpression(node) {
102
+ const write = outboxWriteOf(context, hfs, node)
103
+ if (!write) return
104
+ const call = context.sourceCode.getText(node.callee)
105
+ if (!inDomain) context.report({ node, messageId: "foreignTier", data: { call } })
106
+ else if (!isTransactionManager(context, write.argument)) context.report({ node, messageId: "noTransaction", data: { call } })
107
+ },
108
+ }
109
+ },
110
+ }
111
+
112
+ /** The `eventName` literal of the event class an expression names (`typeof OrderPlacedEvent` has a static `eventName`), or null. */
113
+ const eventNameOf = (context, expression) => {
114
+ const { checker, toTs } = typed(context)
115
+ const tsNode = toTs(expression)
116
+ if (!tsNode) return null
117
+ const property = checker.getTypeAtLocation(tsNode).getProperty("eventName")
118
+ if (!property) return null
119
+ const type = checker.getTypeOfSymbolAtLocation(property, tsNode)
120
+ return type.isStringLiteral() ? type.value : null
121
+ }
122
+
123
+ /** True when the node is a `.eventId` member read on a value typed as the `EventDelivery` of `platform/event-bus`. */
124
+ const readsDeliveryEventId = (context, node) =>
125
+ node.type === "MemberExpression" && !node.computed && node.property.type === "Identifier" && node.property.name === "eventId" && isEventDeliveryType(context, node.object)
126
+
127
+ /** The kebab stem of an event name: dots become dashes. */
128
+ const stemOfEvent = (name) => name.split(".").join("-")
129
+
130
+ /** A message consumer is the one door of an event: placed in `transport/message`, named after its event, and it forwards the event id. */
131
+ export const eventConsumerShape = {
132
+ meta: {
133
+ type: "problem",
134
+ docs: { description: "A class implementing `EventConsumer` is `transport/message/<event>.consumer.ts`, named after its event class's `eventName`, and forwards `delivery.eventId`; a `.consumer.ts` implements `EventConsumer`." },
135
+ schema: [],
136
+ messages: {
137
+ placement: "`{{name}}` implements `EventConsumer` outside a `.consumer.ts` file of the message transport (`transport/message/<event>.consumer.ts`). A consumer is a transport door: move it there.",
138
+ notConsumer: "This `.consumer.ts` file declares `{{name}}`, which does not implement `EventConsumer` of `platform/event-bus`. The message transport holds only event consumers.",
139
+ eventUnknown: "`{{name}}.event` must be an event class whose static `eventName` is a string literal (`static readonly eventName = \"order.placed\"`), so the consumer and its contract entry are one name.",
140
+ stem: "The consumer of `{{event}}` is `{{expected}}.consumer.ts`, but this file is `{{actual}}.consumer.ts`. A consumer is named after the event it handles (dots become dashes).",
141
+ noEventId: "`{{name}}.handle` never reads `delivery.eventId`. Delivery is at least once: pass the event id to the command so the domain service claims it in `platform/inbox` inside its own transaction and a redelivery is a no-op.",
142
+ },
143
+ },
144
+ create(context) {
145
+ const hfs = hfsOf(context)
146
+ const file = context.filename
147
+ if (isExempt(hfs, file)) return {}
148
+ const inTransport = hfs.slotOf(file) === "be.transport.message"
149
+ const isConsumerFile = baseName(file).endsWith(".consumer.ts")
150
+ const check = (node) => {
151
+ const name = node.id?.name ?? "this class"
152
+ const implementsConsumer = (node.implements ?? []).some((entry) => isEventConsumerType(context, entry))
153
+ if (!implementsConsumer) {
154
+ if (inTransport && isConsumerFile) context.report({ node: node.id ?? node, messageId: "notConsumer", data: { name } })
155
+ return
156
+ }
157
+ if (!inTransport || !isConsumerFile) {
158
+ context.report({ node: node.id ?? node, messageId: "placement", data: { name } })
159
+ return
160
+ }
161
+ const eventProperty = node.body.body.find((member) => member.type === "PropertyDefinition" && !member.computed && member.key.type === "Identifier" && member.key.name === "event")
162
+ const event = eventProperty?.value ? eventNameOf(context, eventProperty.value) : null
163
+ if (event === null) context.report({ node: eventProperty ?? node.id ?? node, messageId: "eventUnknown", data: { name } })
164
+ else {
165
+ const actual = baseName(file).slice(0, -".consumer.ts".length)
166
+ if (stemOfEvent(event) !== actual) context.report({ node: node.id ?? node, messageId: "stem", data: { event, expected: stemOfEvent(event), actual } })
167
+ }
168
+ const handle = node.body.body.find((member) => member.type === "MethodDefinition" && !member.computed && member.key.type === "Identifier" && member.key.name === "handle")
169
+ const body = handle?.value?.body
170
+ let forwarded = false
171
+ if (body) walk(body, (child) => { if (readsDeliveryEventId(context, child)) forwarded = true })
172
+ if (!forwarded) context.report({ node: handle ?? node.id ?? node, messageId: "noEventId", data: { name } })
173
+ }
174
+ return { ClassDeclaration: check, ClassExpression: check }
175
+ },
176
+ }
177
+
178
+ /** The functions of an exported class or the exported functions of a `.queue.ts`: the producers. */
179
+ const producersOf = (program) => {
180
+ const found = []
181
+ for (const statement of program.body) {
182
+ const declaration = statement.type === "ExportNamedDeclaration" || statement.type === "ExportDefaultDeclaration" ? statement.declaration : null
183
+ if (!declaration) continue
184
+ if (declaration.type === "FunctionDeclaration") {
185
+ found.push(declaration)
186
+ } else if (declaration.type === "ClassDeclaration") {
187
+ for (const member of declaration.body.body) if (member.type === "MethodDefinition" && member.kind === "method" && member.value) found.push(member.value)
188
+ } else if (declaration.type === "VariableDeclaration") {
189
+ for (const item of declaration.declarations) if (item.init && (item.init.type === "ArrowFunctionExpression" || item.init.type === "FunctionExpression")) found.push(item.init)
190
+ }
191
+ }
192
+ return found
193
+ }
194
+
195
+ /** A producer of a queue passes its own transaction parameter to the outbox, and nothing else writes the outbox there. */
196
+ export const queueProducerShape = {
197
+ meta: {
198
+ type: "problem",
199
+ docs: { description: "In `modules/queues/<queue>/<queue>.queue.ts` every producer that writes the `QueueOutbox` takes a `tx` parameter typed `EntityManager` and passes it as the transaction." },
200
+ schema: [],
201
+ messages: {
202
+ noTransaction: "This producer writes the queue outbox without passing its own `tx` parameter (typed `EntityManager`) as the first argument. A queue job is enqueued inside the caller's transaction: declare `tx: EntityManager` and call `outbox.write(tx, ...)`.",
203
+ },
204
+ },
205
+ create(context) {
206
+ const hfs = hfsOf(context)
207
+ const file = context.filename
208
+ if (hfs.slotOf(file) !== "be.queues" || !baseName(file).endsWith(".queue.ts")) return {}
209
+ return {
210
+ "Program:exit"(program) {
211
+ for (const producer of producersOf(program)) {
212
+ const parameters = producer.params.filter((param) => param.type === "Identifier")
213
+ const tx = parameters.find((param) => infraTypeOf(context, param) === "EntityManager")
214
+ walk(producer.body, (node) => {
215
+ if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression" || !isQueueOutboxType(context, node.callee.object)) return
216
+ const first = node.arguments[0]
217
+ const passes = tx !== undefined && first?.type === "Identifier" && first.name === tx.name && resolveVariable(context.sourceCode, first)?.defs[0]?.name === tx
218
+ if (!passes) context.report({ node, messageId: "noTransaction" })
219
+ })
220
+ }
221
+ },
222
+ }
223
+ },
224
+ }
225
+
226
+ export const rules = {
227
+ "outbox-write-tx": outboxWriteTx,
228
+ "event-consumer-shape": eventConsumerShape,
229
+ "queue-producer-shape": queueProducerShape,
230
+ }
231
+
232
+ /** The level these laws ask for: `error`, switched off nowhere. */
233
+ export const recommended = {
234
+ "starci-be/outbox-write-tx": "error",
235
+ "starci-be/event-consumer-shape": "error",
236
+ "starci-be/queue-producer-shape": "error",
237
+ }