@starci/eslint-canon-be 3.0.8 → 3.0.10

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 (151) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/LICENSE +21 -0
  3. package/README.md +12 -6
  4. package/ambient-id.mjs +90 -0
  5. package/cli.mjs +181 -0
  6. package/comments.mjs +24 -36
  7. package/config-owner.mjs +20 -4
  8. package/connections.mjs +12 -12
  9. package/cookies.mjs +106 -0
  10. package/cqrs.mjs +22 -26
  11. package/data-access.mjs +9 -28
  12. package/default-deny.mjs +2 -1
  13. package/e2e-flow.mjs +4 -4
  14. package/event-bus.mjs +237 -0
  15. package/feature-thin.mjs +237 -0
  16. package/idempotency.mjs +57 -6
  17. package/index.mjs +22 -1
  18. package/infra-owner.mjs +1 -1
  19. package/injection.mjs +67 -22
  20. package/input-bounds.mjs +2 -1
  21. package/jobs.mjs +214 -0
  22. package/lib/ast.mjs +7 -0
  23. package/lib/config.mjs +1 -1
  24. package/lib/db-writes.mjs +1 -1
  25. package/lib/doors.mjs +81 -0
  26. package/lib/edition.mjs +45 -0
  27. package/lib/entity-writes.mjs +35 -0
  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 -2
  39. package/project-graph.mjs +5 -2
  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 +486 -0
  44. package/runtime/engine/invalid-config.mjs +15 -0
  45. package/runtime/engine/orca-config.mjs +27 -0
  46. package/runtime/engine/secrets.mjs +130 -0
  47. package/runtime/knowledge/hfs/canon-pins.yaml +162 -31
  48. package/runtime/knowledge/hfs/rules.yaml +2059 -0
  49. package/runtime/knowledge/hfs/slots.yaml +912 -120
  50. package/runtime/knowledge/patterns/fe/folder.yaml +131 -25
  51. package/runtime/knowledge/sonar-gate.yaml +52 -18
  52. package/runtime/modules/models/registry.yaml +553 -0
  53. package/runtime/modules/models/runtimes.yaml +695 -0
  54. package/runtime/packages/hfs/templates/app/package-scripts/package.json +3 -10
  55. package/runtime/scripts/api/git/config-get.mjs +10 -0
  56. package/runtime/scripts/api/git/lib.mjs +18 -26
  57. package/runtime/scripts/api/git/ls-files.mjs +6 -0
  58. package/runtime/scripts/api/git/remote.mjs +5 -0
  59. package/runtime/scripts/api/git/rev-parse-query.mjs +5 -0
  60. package/runtime/scripts/hfs/architecture/ast-walks.mjs +220 -0
  61. package/runtime/scripts/hfs/architecture/backend.mjs +16 -23
  62. package/runtime/scripts/hfs/architecture/background-unowned.mjs +16 -34
  63. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +3 -1
  64. package/runtime/scripts/hfs/architecture/clones.mjs +4 -4
  65. package/runtime/scripts/hfs/architecture/config.mjs +9 -6
  66. package/runtime/scripts/hfs/architecture/connection-map.mjs +20 -8
  67. package/runtime/scripts/hfs/architecture/context-coupling.mjs +88 -0
  68. package/runtime/scripts/hfs/architecture/context-map.mjs +237 -0
  69. package/runtime/scripts/hfs/architecture/context-owner.mjs +82 -0
  70. package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +47 -0
  71. package/runtime/scripts/hfs/architecture/context-transaction.mjs +60 -0
  72. package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +4 -10
  73. package/runtime/scripts/hfs/architecture/contracts.mjs +33 -162
  74. package/runtime/scripts/hfs/architecture/dead-exports.mjs +58 -29
  75. package/runtime/scripts/hfs/architecture/doc-language.mjs +1 -1
  76. package/runtime/scripts/hfs/architecture/entrypoint.mjs +2 -1
  77. package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -2
  78. package/runtime/scripts/hfs/architecture/feature-shape.mjs +5 -2
  79. package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
  80. package/runtime/scripts/hfs/architecture/frontend.mjs +17 -43
  81. package/runtime/scripts/hfs/architecture/hfs.mjs +24 -39
  82. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +1 -1
  83. package/runtime/scripts/hfs/architecture/index.mjs +17 -4
  84. package/runtime/scripts/hfs/architecture/machine-ast.mjs +44 -11
  85. package/runtime/scripts/hfs/architecture/managed-scripts.mjs +27 -0
  86. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +30 -6
  87. package/runtime/scripts/hfs/architecture/next-data.mjs +22 -63
  88. package/runtime/scripts/hfs/architecture/owners.mjs +35 -15
  89. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -1
  90. package/runtime/scripts/hfs/architecture/registration.mjs +23 -81
  91. package/runtime/scripts/hfs/architecture/required-files.mjs +20 -12
  92. package/runtime/scripts/hfs/architecture/schema-owner.mjs +17 -114
  93. package/runtime/scripts/hfs/architecture/server-action.mjs +24 -0
  94. package/runtime/scripts/hfs/architecture/side-boundary.mjs +16 -0
  95. package/runtime/scripts/hfs/architecture/source-names.mjs +27 -147
  96. package/runtime/scripts/hfs/architecture/sql-owner.mjs +59 -18
  97. package/runtime/scripts/hfs/architecture/sql-returning.mjs +64 -0
  98. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +111 -0
  99. package/runtime/scripts/hfs/architecture/supabase-be.mjs +131 -0
  100. package/runtime/scripts/hfs/architecture/supabase-tables.mjs +42 -0
  101. package/runtime/scripts/hfs/architecture/supabase.mjs +434 -0
  102. package/runtime/scripts/hfs/architecture/surface.mjs +9 -3
  103. package/runtime/scripts/hfs/architecture/test-world-files.mjs +9 -19
  104. package/runtime/scripts/hfs/architecture/tiers.mjs +47 -5
  105. package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -3
  106. package/runtime/scripts/hfs/architecture/typescript.mjs +12 -26
  107. package/runtime/scripts/hfs/declaration-shape.mjs +62 -0
  108. package/runtime/scripts/hfs/declaration-slots.mjs +75 -0
  109. package/runtime/scripts/hfs/edition-slots.mjs +66 -0
  110. package/runtime/scripts/hfs/manifest-shape.mjs +109 -32
  111. package/runtime/scripts/hfs/param-names.mjs +18 -0
  112. package/runtime/scripts/hfs/path-findings.mjs +1 -1
  113. package/runtime/scripts/hfs/project-graph.mjs +3 -3
  114. package/runtime/scripts/hfs/repo-identity.mjs +9 -6
  115. package/runtime/scripts/hfs/rule-params-shape.mjs +50 -0
  116. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +6 -5
  117. package/runtime/scripts/hfs/rules/read.mjs +1 -1
  118. package/runtime/scripts/hfs/rules/secrets.mjs +3 -3
  119. package/runtime/scripts/hfs/rules/spec-placement.mjs +1 -1
  120. package/runtime/scripts/hfs/slots.mjs +78 -108
  121. package/runtime/scripts/hfs/test-world-slot.mjs +6 -0
  122. package/runtime/scripts/hfs/typescript-programs.mjs +0 -5
  123. package/runtime/scripts/hfs/view.mjs +3 -2
  124. package/runtime/scripts/lib/dockerfile.mjs +124 -0
  125. package/runtime/scripts/lib/git.mjs +36 -0
  126. package/runtime/scripts/lib/i18n.mjs +86 -0
  127. package/runtime/scripts/lib/jsdoc.mjs +35 -0
  128. package/runtime/scripts/lib/json.mjs +13 -3
  129. package/runtime/scripts/lib/language.mjs +24 -5
  130. package/runtime/scripts/lib/list.mjs +20 -0
  131. package/runtime/scripts/lib/mutation-fence.mjs +15 -0
  132. package/runtime/scripts/lib/path-key.mjs +57 -2
  133. package/runtime/scripts/lib/read-text.mjs +34 -0
  134. package/runtime/scripts/lib/regex.mjs +4 -0
  135. package/runtime/scripts/lib/secret-patterns.mjs +4 -3
  136. package/runtime/scripts/lib/sops-envelope.mjs +112 -0
  137. package/runtime/scripts/lib/stack-services.mjs +7 -31
  138. package/runtime/scripts/lib/ts-ast.mjs +35 -0
  139. package/runtime/scripts/lib/walk.mjs +4 -3
  140. package/schema-authority.mjs +14 -12
  141. package/service-deps.mjs +7 -13
  142. package/service-isolation.mjs +72 -0
  143. package/size-budget.mjs +1 -1
  144. package/test-world.mjs +5 -4
  145. package/testing.mjs +27 -24
  146. package/transport.mjs +12 -9
  147. package/unit-spec.mjs +1 -1
  148. package/user-copy.mjs +5 -4
  149. package/webhooks.mjs +173 -0
  150. package/runtime/scripts/api/sops/decrypt.mjs +0 -16
  151. package/runtime/scripts/lib/test-secrets.mjs +0 -116
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
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * The rule that holds the thin-feature law (R203 `BE_FEATURE_THIN`).
3
+ *
4
+ * A file of the feature tier whose role is one of `ruleParams.be.thinRoles` of the slot manifest
5
+ * (`<name>.handler.ts`, `<name>.resolver.ts`, `<name>.consumer.ts`, `<name>.processor.ts`, `<name>.step.ts`,
6
+ * `<name>.webhook.ts`, `<name>.mapper.ts`, ...) is a door: it maps its parameters and makes ONE delegating
7
+ * call into the modules (a service, the command or query bus, the event bus, a queue producer). No branch,
8
+ * loop, `try` or `throw` on business data and no computation lives in it - the logic belongs to the modules,
9
+ * where every file is unit-covered at 100, and orchestration (step order, compensation, claim, fencing,
10
+ * retry) belongs to the platform. A `mapper` file holds only property mapping: `*.mapper.ts` calls, `new`, and the shape conversions `map` and `toISOString`
11
+ * expressions, no delegating call at all.
12
+ *
13
+ * Kind comes from the SLOT and the TIER (`hfs.tierOf(filename)`, `hfs.ruleParams.thinRoles`), the role from
14
+ * the file-name suffix the closed role vocabulary defines - never a path regex. What a receiver is comes
15
+ * from its TYPE (`Logger` of `platform/logging`, `WebhookSignatureService` of `platform/http-security`) and
16
+ * from whether the member is injected (`injectedMembers`); what a free function is comes from the file its
17
+ * import resolves to. A job `step` records its fence through `JobClaims` (`advance`, `runKey`) and a webhook proves its signature through `verify`: those gate calls
18
+ * are free, the one effect each makes is the delegating call. `.spec.ts` and `.d.ts` are never judged, and neither is a file whose tier is not
19
+ * `feature` or whose role is not a thin role. A clean thin file is never flagged: that is the bar.
20
+ */
21
+ import { keyName, walk } from "./lib/ast.mjs"
22
+ import { aliasTarget, injectedMembers } from "./lib/doors.mjs"
23
+ import { hfsOf } from "./lib/hfs.mjs"
24
+ import { importOf } from "./lib/import-source.mjs"
25
+ import { baseName, isLoggerType, isOwnedType } from "./lib/ports.mjs"
26
+
27
+ /** The statements and expressions that are a decision, a repetition or an exit: none lives in a thin file. */
28
+ const BRANCH_NODES = new Set(["IfStatement", "SwitchStatement", "ConditionalExpression", "ForStatement", "ForInStatement", "ForOfStatement", "WhileStatement", "DoWhileStatement", "TryStatement", "ThrowStatement", "LogicalExpression"])
29
+
30
+ /** The expressions that compute a value (a `TemplateLiteral` with an expression joins them). */
31
+ const COMPUTE_NODES = new Set(["BinaryExpression", "UnaryExpression", "UpdateExpression", "AssignmentExpression"])
32
+
33
+ /** The nodes a function can be: the walk for calls and the nesting test use the same set. */
34
+ const FUNCTION_NODES = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression", "MethodDefinition"])
35
+
36
+ /** What one decision or loop node is called in a message. */
37
+ const BRANCH_WHAT = {
38
+ IfStatement: "an `if`",
39
+ SwitchStatement: "a `switch`",
40
+ ConditionalExpression: "a ternary",
41
+ ForStatement: "a `for`",
42
+ ForInStatement: "a `for-in`",
43
+ ForOfStatement: "a `for-of`",
44
+ WhileStatement: "a `while`",
45
+ DoWhileStatement: "a `do-while`",
46
+ TryStatement: "a `try`",
47
+ ThrowStatement: "a `throw`",
48
+ }
49
+
50
+ /** The wraps a call may sit under while still being the call of its statement. */
51
+ const STATEMENT_WRAPPERS = new Set(["AwaitExpression", "TSAsExpression", "TSSatisfiesExpression", "TSNonNullExpression", "ChainExpression"])
52
+
53
+ /** The closed-vocabulary role a file name carries (`<name>.<role>.ts` gives `<role>`), or null. */
54
+ const roleOf = (filename) => /\.([a-z][a-z0-9-]*)\.ts$/.exec(baseName(filename))?.[1] ?? null
55
+
56
+ /** The `WebhookSignatureService` of `platform/http-security` - the `verify` a webhook door may call. */
57
+ const isSignatureReceiver = (context, node) => isOwnedType(context, node, { name: "WebhookSignatureService", capability: "http-security", tier: "platform" })
58
+
59
+ /** The shape conversions a mapper may make on a value it received: a collection mapped item by item and a date written as text. They decide nothing and compute nothing. */
60
+ const MAPPER_SHAPE_METHODS = new Set(["map", "toISOString"])
61
+
62
+ /** The `JobClaims` port of `platform/jobs` - the fence a job step records itself through (`advance`, `runKey`): bookkeeping the fenced-job pattern requires, free beside the step's one delegating call. */
63
+ const isClaimsReceiver = (context, node) => isOwnedType(context, node, { name: "JobClaims", capability: "jobs", tier: "platform" })
64
+
65
+ /** The `Logger` port of `platform/logging` - the one call that is always free. */
66
+ const isLoggerReceiver = isLoggerType
67
+
68
+ /** The name of a thin file's law is `feature-thin` (R203). */
69
+ export const featureThin = {
70
+ meta: {
71
+ type: "problem",
72
+ docs: { description: "A feature-tier file of a thin role maps its parameters and makes one delegating call into the modules: no branch, loop, `try`, `throw` or computation, and no call other than the one delegate, a modules function in mapping position, a `*.mapper.ts` function or the Logger." },
73
+ schema: [],
74
+ messages: {
75
+ branch: "{{what}} is a decision or a repetition in a thin file. A feature `<name>.{{role}}.ts` maps its parameters and makes one delegating call into the modules; a branch, a loop, a `try` or a `throw` is business logic that belongs in a service of the modules (where a unit spec covers it) - and orchestration (step order, compensation, claim, fencing, retry) belongs to the platform.",
76
+ compute: "{{what}} computes a value in a thin file. A feature `<name>.{{role}}.ts` maps its parameters and hands them over; an expression that computes is logic that belongs in the modules. Map the value there and read it back.",
77
+ call: "`{{what}}` is a call a thin file does not make. It calls exactly one injected member (a service, a bus `execute`, an event bus, a queue producer) or one function of the modules, and pure `*.mapper.ts` functions and the `Logger` port are free{{webhook}}; everything else - parsing, mapping by hand, a helper, a second dependency - is logic that belongs in the modules.",
78
+ many: "`{{name}}` makes a second delegating call here. A thin file makes exactly one; a second is orchestration - step order, compensation, claim, fencing, retry - and belongs to a capability of the platform or inside the service the file calls.",
79
+ },
80
+ },
81
+ create(context) {
82
+ const hfs = hfsOf(context)
83
+ const name = baseName(context.filename)
84
+ if (name.endsWith(".spec.ts") || name.endsWith(".d.ts")) return {}
85
+ const role = roleOf(name)
86
+ if (role === null || !(hfs.ruleParams.thinRoles ?? []).includes(role)) return {}
87
+ if (hfs.tierOf(context.filename) !== "feature") return {}
88
+ const sourceCode = context.sourceCode
89
+ const mapperFile = role === "mapper"
90
+
91
+ /** The injected member names of a class: constructor parameter properties and properties carrying an injector decorator. */
92
+ const memberNames = new Map()
93
+ const injectedNamesOf = (classNode) => {
94
+ if (memberNames.has(classNode)) return memberNames.get(classNode)
95
+ const names = new Set()
96
+ for (const { node } of injectedMembers(context, classNode)) {
97
+ if (node.type === "TSParameterProperty" && node.parameter.type === "Identifier") names.add(node.parameter.name)
98
+ else if (node.type === "PropertyDefinition") {
99
+ const key = keyName(node.key)
100
+ if (key !== null) names.add(key)
101
+ }
102
+ }
103
+ memberNames.set(classNode, names)
104
+ return names
105
+ }
106
+
107
+ /**
108
+ * The class `this` binds at a node's position: the nearest enclosing class, or null when a non-arrow
109
+ * function or an object-literal method rebinds `this` in between (a method's own `FunctionExpression`
110
+ * value is skipped - a method's `this` is the class).
111
+ */
112
+ const thisClassOf = (node) => {
113
+ for (let parent = node.parent; parent; parent = parent.parent) {
114
+ if (parent.type === "ClassDeclaration" || parent.type === "ClassExpression") return parent
115
+ if (parent.type === "FunctionDeclaration") return null
116
+ if (parent.type === "FunctionExpression" && parent.parent?.type !== "MethodDefinition") return null
117
+ }
118
+ return null
119
+ }
120
+
121
+ /** The member name of `this.<member>` the call's receiver names, when it is an injected member of the enclosing class. */
122
+ const injectedMemberOf = (object, call) => {
123
+ if (object.type !== "MemberExpression" || object.computed || object.object.type !== "ThisExpression" || object.property.type !== "Identifier") return null
124
+ const classNode = thisClassOf(call)
125
+ if (classNode === null) return null
126
+ const member = object.property.name
127
+ return injectedNamesOf(classNode).has(member) ? member : null
128
+ }
129
+
130
+ /** True for a call `f(...)` where `f` is imported from a `*.mapper.ts` (the pure mapping functions). */
131
+ const isMapperCall = (callee) => {
132
+ if (callee.type !== "Identifier") return false
133
+ const found = importOf(context, callee)
134
+ return found !== null && /(?:^|\/)[^/]+\.mapper(?:\.[cm]?[jt]s)?$/.test(found.source.replace(/\\/g, "/"))
135
+ }
136
+
137
+ /** True for a call `f(...)` where `f` is imported and its declaration lives outside the feature tier (a function of the modules). */
138
+ const isModulesCall = (callee) => {
139
+ if (callee.type !== "Identifier" || importOf(context, callee) === null) return false
140
+ const symbol = aliasTarget(context, callee)
141
+ return (symbol?.getDeclarations() ?? []).some((declaration) => hfs.tierOf(declaration.getSourceFile().fileName) !== "feature")
142
+ }
143
+
144
+ /** True when the call IS the statement it sits in: `f(...)`, `await f(...)`, `return (await) f(...)`, or the whole body of a concise arrow. */
145
+ const isStatementCall = (call) => {
146
+ let current = call
147
+ while (STATEMENT_WRAPPERS.has(current.parent?.type)) current = current.parent
148
+ const parent = current.parent
149
+ return parent?.type === "ExpressionStatement" || parent?.type === "ReturnStatement" || (parent?.type === "ArrowFunctionExpression" && parent.body === current)
150
+ }
151
+
152
+ /**
153
+ * What one call is to a thin file: `delegating` (the one call into the modules a file may make),
154
+ * `free` (a mapper, a logger method, a webhook `verify`, a job step's `JobClaims` fence call, a modules function in a mapping position)
155
+ * or `call` (a call a thin file does not make). A `mapper` file holds only mapper calls and `new`.
156
+ */
157
+ const classify = (call) => {
158
+ const callee = call.callee
159
+ if (callee.type === "Identifier" && isMapperCall(callee)) return "free"
160
+ const member = callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" ? callee : null
161
+ if (mapperFile && member !== null && MAPPER_SHAPE_METHODS.has(member.property.name) && injectedMemberOf(member.object, call) === null) return "free"
162
+ // a cli group command shows its help without a sub-command: the `help` of the command nest-commander gives it
163
+ if (role === "cli" && member !== null && member.property.name === "help" && member.object.type === "MemberExpression" && member.object.object.type === "ThisExpression" && member.object.property.type === "Identifier" && member.object.property.name === "command") return "free"
164
+ if (mapperFile) return "call"
165
+ if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && injectedMemberOf(callee.object, call) !== null) {
166
+ if (isLoggerReceiver(context, callee.object)) return "free"
167
+ if (role === "webhook" && callee.property.name === "verify" && isSignatureReceiver(context, callee.object)) return "free"
168
+ if (role === "step" && isClaimsReceiver(context, callee.object)) return "free"
169
+ return "delegating"
170
+ }
171
+ if (callee.type === "Identifier" && isModulesCall(callee)) return isStatementCall(call) ? "delegating" : "free"
172
+ return "call"
173
+ }
174
+
175
+ /** Every root function of the file (a method body, function or arrow not nested in another function and not inside a decorator subtree). */
176
+ const rootFunctionsOf = (program) => {
177
+ const roots = []
178
+ walk(program, (node) => {
179
+ if (!FUNCTION_NODES.has(node.type)) return
180
+ for (let parent = node.parent; parent; parent = parent.parent) {
181
+ if (parent.type === "Decorator" || FUNCTION_NODES.has(parent.type)) return
182
+ }
183
+ if (node.type === "MethodDefinition" && node.kind === "constructor") return
184
+ roots.push(node)
185
+ })
186
+ return roots
187
+ }
188
+
189
+ /** The name a `many` message reports for a function root. */
190
+ const nameOf = (fn) => {
191
+ if (fn.type === "MethodDefinition") return keyName(fn.key) ?? "the method"
192
+ if (fn.type === "FunctionDeclaration" && fn.id?.type === "Identifier") return fn.id.name
193
+ const parent = fn.parent
194
+ if (parent?.type === "VariableDeclarator" && parent.id.type === "Identifier") return parent.id.name
195
+ if ((parent?.type === "PropertyDefinition" || parent?.type === "Property") && keyName(parent.key)) return keyName(parent.key)
196
+ return "the function"
197
+ }
198
+
199
+ const visitors = {}
200
+ for (const type of BRANCH_NODES) {
201
+ visitors[type] = (node) => context.report({ node, messageId: "branch", data: { what: type === "LogicalExpression" ? `a \`${node.operator}\`` : BRANCH_WHAT[type], role } })
202
+ }
203
+ for (const type of COMPUTE_NODES) {
204
+ visitors[type] = (node) => context.report({ node, messageId: "compute", data: { what: `an \`${node.operator}\``, role } })
205
+ }
206
+ visitors.TemplateLiteral = (node) => {
207
+ if (node.expressions.length > 0) context.report({ node, messageId: "compute", data: { what: "a template literal with an expression", role } })
208
+ }
209
+ visitors["Program:exit"] = (program) => {
210
+ for (const root of rootFunctionsOf(program)) {
211
+ const body = root.type === "MethodDefinition" ? root.value?.body : root.body
212
+ if (!body) continue
213
+ let delegating = 0
214
+ walk(body, (node) => {
215
+ if (node.type !== "CallExpression") return
216
+ const kind = classify(node)
217
+ if (kind === "call") context.report({ node, messageId: "call", data: { what: sourceCode.getText(node.callee), webhook: role === "webhook" ? ", plus `verify` of the signature service" : "" } })
218
+ else if (kind === "delegating") {
219
+ delegating += 1
220
+ if (delegating === 2) context.report({ node, messageId: "many", data: { name: nameOf(root) } })
221
+ }
222
+ })
223
+ }
224
+ }
225
+ return visitors
226
+ },
227
+ }
228
+
229
+ /** The rules this law contributes to the plugin. */
230
+ export const rules = {
231
+ "feature-thin": featureThin,
232
+ }
233
+
234
+ /** Every rule of this law at `error`. */
235
+ export const recommended = {
236
+ "starci-be/feature-thin": "error",
237
+ }
package/idempotency.mjs CHANGED
@@ -9,8 +9,8 @@
9
9
  * - `inbox-dedupe-required` judges every DELIVERY method of a service. Doors (consumers, `SignedWebhook` controllers) are
10
10
  * thin (R88): they map the delivery and dispatch one message, so the claim lives in the `*.service.ts` the handler calls,
11
11
  * inside its transaction. A delivery method is a public method of a `<name>.service.ts` whose FIRST parameter has an
12
- * `eventId` property, read by the parameter's TYPE. The FIRST awaited expression of such a method must be
13
- * `claim(source, eventId)` on a receiver whose type is the `Inbox` port of `platform/inbox`, and the method must return
12
+ * `eventId` property, read by the parameter's TYPE. Its first awaited work is the Inbox claim, directly or inside
13
+ * the callback of its own EntityManager transaction, with that callback's manager, and the method must return
14
14
  * early when the claim answers `false`. A lookalike `Inbox` declared by another owner, a renamed receiver and a
15
15
  * property injection are all judged by the receiver's type, so only the real port satisfies the rule; a keyword or a
16
16
  * class name never does.
@@ -20,12 +20,15 @@
20
20
  * if ((await this.inbox.claim(source, id)) === false) { return }
21
21
  * const fresh = await this.inbox.claim(source, id)
22
22
  * if (!fresh) return
23
- * Whether the handler has side effects BEFORE its first await stays a review question (the call graph is not read).
23
+ * A transaction delivery returns or awaits one transaction as its sole statement. The actual selected inline callback
24
+ * claims with its own manager in its first statement, then returns on false; no outer work runs after a duplicate.
25
+ * Direct-claim synchronous effects, getters and called helpers stay review questions (the call graph is not read).
24
26
  */
25
27
  import { hfsOf } from "./lib/hfs.mjs"
26
28
  import { walk } from "./lib/ast.mjs"
27
- import { baseName, isOwnedType } from "./lib/ports.mjs"
28
- import { typed } from "./lib/types.mjs"
29
+ import { baseName, isOwnedType, ownerNameOf } from "./lib/ports.mjs"
30
+ import { resolveVariable } from "./lib/transactions.mjs"
31
+ import { isPackageType, typed } from "./lib/types.mjs"
29
32
 
30
33
  /** The `Inbox` port of `platform/inbox`. */
31
34
  const isInboxType = (context, node) => isOwnedType(context, node, { name: "Inbox", capability: "inbox", tier: "platform" })
@@ -73,6 +76,52 @@ const firstAwait = (body) => {
73
76
  return first
74
77
  }
75
78
 
79
+ /** True when an expression evaluates a call, construction or write before the designated call completes. */
80
+ const hasPriorEffect = (expression, call) => {
81
+ let found = false
82
+ walk(expression, (node) => {
83
+ if (["CallExpression", "NewExpression", "AssignmentExpression", "UpdateExpression"].includes(node.type)
84
+ && node !== call && node.range[1] < call.range[1]) found = true
85
+ }, { intoFunctions: false })
86
+ return found
87
+ }
88
+
89
+ /** The first awaited claim in the actual callback of a sole, returned or awaited EntityManager transaction. */
90
+ const transactionClaim = (context, body) => {
91
+ if (body.body.length !== 1) return null
92
+ const outer = body.body[0]
93
+ const expression = outer.type === "ReturnStatement" ? outer.argument
94
+ : outer.type === "ExpressionStatement" && outer.expression.type === "AwaitExpression" ? outer.expression : null
95
+ const call = expression?.type === "AwaitExpression" ? expression.argument : expression
96
+ const callee = call?.callee
97
+ if (call?.type !== "CallExpression" || callee.type !== "MemberExpression" || callee.computed
98
+ || callee.property.type !== "Identifier" || callee.property.name !== "transaction"
99
+ || !isPackageType(context, callee.object, "EntityManager", "typeorm") || hasPriorEffect(call, call)) return null
100
+ // TypeORM runs arg0 when it is a function, otherwise arg1. Never accept a later, ignored callback.
101
+ const isolation = call.arguments[0]
102
+ const callback = call.arguments.length === 1 ? isolation
103
+ : call.arguments.length === 2 && isolation.type === "Literal"
104
+ && ["READ UNCOMMITTED", "READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"].includes(isolation.value)
105
+ ? call.arguments[1] : null
106
+ if (!callback || !["ArrowFunctionExpression", "FunctionExpression"].includes(callback.type)
107
+ || !callback.async || callback.body.type !== "BlockStatement" || callback.params.length !== 1) return null
108
+ const manager = callback.params[0]
109
+ if (manager.type !== "Identifier" || !isPackageType(context, manager, "EntityManager", "typeorm")) return null
110
+ const awaited = firstAwait(callback.body)
111
+ const parent = awaited?.parent
112
+ const statement = parent?.type === "VariableDeclarator" && parent.init === awaited
113
+ && parent.parent.declarations.length === 1 ? parent.parent
114
+ : ["UnaryExpression", "BinaryExpression"].includes(parent?.type) ? parent.parent
115
+ : parent?.type === "ExpressionStatement" ? parent : null
116
+ if (statement !== callback.body.body[0] || statement?.parent !== callback.body) return null
117
+ const claim = awaited.argument
118
+ const argument = claim?.type === "CallExpression" ? claim.arguments[2] : null
119
+ const managerBinding = resolveVariable(context.sourceCode, manager)
120
+ const argumentBinding = argument?.type === "Identifier" ? resolveVariable(context.sourceCode, argument) : null
121
+ if (!managerBinding || managerBinding.defs[0]?.name !== manager || !argumentBinding
122
+ || argumentBinding !== managerBinding || hasPriorEffect(statement, claim)) return null
123
+ return awaited
124
+ }
76
125
  /** Whether a parameter's type has an `eventId` property: the shape of a delivery. */
77
126
  const isDeliveryParam = (context, param) => {
78
127
  const target = param.type === "TSParameterProperty" ? param.parameter : param.type === "AssignmentPattern" ? param.left : param
@@ -99,13 +148,15 @@ export const inboxDedupeRequired = {
99
148
  const filename = context.filename || context.getFilename()
100
149
  const slot = hfs.slotOf(filename)
101
150
  if (!baseName(filename).endsWith(".service.ts") || !slot || slot.startsWith("be.tests.")) return {}
151
+ // The event bus publishes events (their shape carries an `eventId`) and its runtime claims the inbox before a consumer runs: no delivery method here.
152
+ if (slot.startsWith("be.platform") && ownerNameOf(hfs, filename) === "event-bus") return {}
102
153
  return {
103
154
  MethodDefinition(node) {
104
155
  if (node.kind !== "method" || node.static || node.key.type === "PrivateIdentifier" || !node.value.body) return
105
156
  if (node.accessibility === "private" || node.accessibility === "protected") return
106
157
  const first = node.value.params[0]
107
158
  if (!first || !isDeliveryParam(context, first)) return
108
- const awaited = firstAwait(node.value.body)
159
+ const awaited = transactionClaim(context, node.value.body) ?? firstAwait(node.value.body)
109
160
  const call = awaited?.argument
110
161
  const claims = call?.type === "CallExpression"
111
162
  && call.callee.type === "MemberExpression"