@starci/eslint-canon-be 3.3.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +2 -2
  3. package/app-composition.mjs +348 -3
  4. package/authorization.mjs +68 -2
  5. package/builders.mjs +2 -2
  6. package/config-owner.mjs +44 -15
  7. package/default-deny.mjs +80 -9
  8. package/edge-hardening.mjs +139 -0
  9. package/feature-thin.mjs +78 -2
  10. package/function-budget.mjs +49 -0
  11. package/health-ready.mjs +539 -0
  12. package/index.mjs +37 -0
  13. package/infra-owner.mjs +3 -0
  14. package/jwt-alg.mjs +45 -0
  15. package/lib/subject-values.mjs +55 -0
  16. package/lib/unit-spec.mjs +0 -3
  17. package/lib/webhook-handoff-inbox.mjs +260 -0
  18. package/lib/webhook-handoff-intake.mjs +140 -0
  19. package/lib/why.mjs +13 -0
  20. package/module-shape.mjs +147 -51
  21. package/operations.mjs +165 -1
  22. package/package.json +1 -1
  23. package/realtime.mjs +59 -22
  24. package/resilience.mjs +45 -14
  25. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  26. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  27. package/runtime/knowledge/hfs/rules.yaml +3401 -152
  28. package/runtime/knowledge/hfs/slots.yaml +344 -62
  29. package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
  30. package/runtime/knowledge/sonar-gate.yaml +1 -1
  31. package/runtime/modules/models/runtimes.yaml +1 -1
  32. package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
  33. package/runtime/scripts/api/git/lib.mjs +3 -0
  34. package/runtime/scripts/api/git/merge-base.mjs +9 -0
  35. package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
  36. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
  37. package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
  38. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  39. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  40. package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
  41. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  42. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  43. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  44. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  45. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
  46. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  47. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  48. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  49. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  50. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
  51. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  52. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
  53. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
  54. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  55. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  56. package/runtime/scripts/hfs/architecture/tiers.mjs +3 -3
  57. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
  58. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  59. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  60. package/runtime/scripts/hfs/manifest-shape.mjs +67 -7
  61. package/runtime/scripts/hfs/project-rule.mjs +3 -1
  62. package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
  63. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  64. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  65. package/runtime/scripts/hfs/slot-imports.mjs +18 -5
  66. package/runtime/scripts/hfs/slots.mjs +1 -0
  67. package/runtime/scripts/lib/env.mjs +20 -0
  68. package/runtime/scripts/lib/event-contract.mjs +107 -0
  69. package/runtime/scripts/lib/json-clone.mjs +61 -0
  70. package/runtime/scripts/lib/list.mjs +4 -0
  71. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  72. package/runtime/scripts/lib/stack-services.mjs +1 -1
  73. package/runtime/scripts/lib/ts-analysis.mjs +5 -2
  74. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  75. package/service-isolation.mjs +3 -0
  76. package/size-budget.mjs +9 -5
  77. package/third-party-owner.mjs +149 -0
  78. package/transport.mjs +11 -29
  79. package/unit-spec.mjs +270 -84
  80. package/webhooks.mjs +169 -0
@@ -0,0 +1,55 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import ts from "typescript"
4
+ import { functionSubjectExports, valueObjectSubjects } from "../runtime/scripts/hfs/architecture/function-spec-providers.mjs"
5
+ import { importOf } from "./import-source.mjs"
6
+ import { typed } from "./types.mjs"
7
+
8
+ /** The exported real function of a function-only sibling subject, with aliases resolved by TypeScript. */
9
+ const realSubjectFunction = (context, value) => {
10
+ if (value.type !== "Identifier") return null
11
+ const imported = importOf(context, value)
12
+ if (!imported || !imported.source.startsWith(".")) return null
13
+ const { checker, toTs } = typed(context)
14
+ const tsNode = toTs(value)
15
+ const symbol = ts.isShorthandPropertyAssignment(tsNode.parent)
16
+ ? checker.getShorthandAssignmentValueSymbol(tsNode.parent) : checker.getSymbolAtLocation(tsNode)
17
+ const alias = symbol?.declarations?.some(declaration => ts.isImportSpecifier(declaration))
18
+ const target = alias ? checker.getAliasedSymbol(symbol) : symbol
19
+ const declaration = target?.valueDeclaration
20
+ if (!declaration) return null
21
+ const sourceFile = declaration.getSourceFile()
22
+ const sibling = (context.filename || context.getFilename()).replace(/\.spec\.ts$/u, ".ts")
23
+ if (path.resolve(sourceFile.fileName) !== path.resolve(sibling)) return null
24
+ const names = functionSubjectExports(ts, sourceFile)
25
+ return names?.includes(imported.imported) ? imported.imported : null
26
+ }
27
+
28
+ const VALUE_OBJECTS = new Map()
29
+
30
+ /** The sibling module's value-object classes (undecorated, no constructor injection token), read from disk once per file; `[]` when none. */
31
+ export const valueObjectsOfSibling = (context) => {
32
+ const sibling = path.resolve((context.filename || context.getFilename()).replace(/\.spec\.ts$/u, ".ts"))
33
+ const stamp = fs.existsSync(sibling) ? fs.statSync(sibling).mtimeMs : -1
34
+ const cached = VALUE_OBJECTS.get(sibling)
35
+ if (cached?.stamp === stamp) return cached.names
36
+ const names = stamp < 0 ? [] : valueObjectSubjects(ts, ts.createSourceFile(sibling, fs.readFileSync(sibling, "utf8"), ts.ScriptTarget.Latest, true)) ?? []
37
+ VALUE_OBJECTS.set(sibling, { stamp, names })
38
+ return names
39
+ }
40
+
41
+ /** `new Subject(...)` where Subject is a value-object class imported from the sibling module. */
42
+ const isValueObjectSubject = (context, value) => {
43
+ if (value.type !== "NewExpression" || value.callee.type !== "Identifier") return false
44
+ const imported = importOf(context, value.callee)
45
+ return Boolean(imported && imported.source.startsWith(".") && valueObjectsOfSibling(context).includes(imported.imported))
46
+ }
47
+
48
+ /** Real functions under test are subject values; an inline fake or a collaborator remains a kit double. */
49
+ export const isRealSubjectValue = (context, value) => {
50
+ if (isValueObjectSubject(context, value)) return true
51
+ if (value.type === "Identifier") return realSubjectFunction(context, value) !== null
52
+ if (value.type !== "ObjectExpression" || value.properties.length === 0) return false
53
+ return value.properties.every((property) => property.type === "Property" && !property.computed
54
+ && (property.key.name ?? property.key.value) === realSubjectFunction(context, property.value))
55
+ }
package/lib/unit-spec.mjs CHANGED
@@ -80,9 +80,6 @@ export const unitRoleOfSubject = (hfs, filename) => {
80
80
  return unitRoles(hfs).find((role) => name.endsWith(`.${role.role}.ts`) && !name.endsWith(`.${role.spec}.ts`) && inRoleSlot(hfs, role, filename)) ?? null
81
81
  }
82
82
 
83
- /** The `<name>` of a `<name>.service.spec.ts`. */
84
- export const serviceNameOfSpec = (filename) => baseOf(filename).replace(/\.service\.spec\.ts$/, "")
85
-
86
83
  /** The slots of the feature kinds whose door carries its own unit spec beside it. */
87
84
  const DOOR_SPEC_SLOTS = new Set(["be.feature.webhooks.http", "be.feature.realtime.graphql", "be.feature.realtime.websocket"])
88
85
 
@@ -0,0 +1,260 @@
1
+ /** R263 extends inbox dedupe to scalar delivery ids and signed raw-body intakes. */
2
+ import ts from "typescript"
3
+ import { walk } from "./ast.mjs"
4
+ import { hfsOf } from "./hfs.mjs"
5
+ import { baseName, isOwnedType, ownerNameOf } from "./ports.mjs"
6
+ import { resolveVariable } from "./transactions.mjs"
7
+ import { isPackageType, typed } from "./types.mjs"
8
+ import { webhookIntakeProcessesDelivery } from "./webhook-handoff-intake.mjs"
9
+
10
+ const DELIVERY_ID = /Id$/
11
+
12
+ /** The slot of provider webhook doors (`<provider>.webhook.ts`), the only entrance of a delivery intake. */
13
+ const DOOR_SLOT = "be.feature.webhooks.http"
14
+
15
+ /** The method declarations each program's webhook doors call, computed once per program. */
16
+ const reachedByProgram = new WeakMap()
17
+
18
+ /** Declarations a call resolves to: its signature's declaration and its (alias-resolved) callee symbol's declarations. */
19
+ const calledDeclarations = (checker, call) => {
20
+ const expression = call.expression
21
+ const name = ts.isPropertyAccessExpression(expression) ? expression.name
22
+ : ts.isElementAccessExpression(expression) ? expression.argumentExpression : expression
23
+ const symbol = checker.getSymbolAtLocation(name)
24
+ const target = symbol && symbol.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol
25
+ return [checker.getResolvedSignature(call)?.declaration, ...(target?.getDeclarations() ?? [])].filter(Boolean)
26
+ }
27
+
28
+ /** Every method a webhook door file (slot be.feature.webhooks.http, `*.webhook.ts`) calls, by resolved declaration. */
29
+ const doorCalledDeclarations = (context, hfs) => {
30
+ const { program, checker } = typed(context)
31
+ const cached = reachedByProgram.get(program)
32
+ if (cached) return cached
33
+ const reached = new Set()
34
+ for (const source of program.getSourceFiles()) {
35
+ if (hfs.slotOf(source.fileName) !== DOOR_SLOT || !baseName(source.fileName).endsWith(".webhook.ts")) continue
36
+ const visit = (node) => {
37
+ if (ts.isCallExpression(node)) for (const declaration of calledDeclarations(checker, node)) reached.add(declaration)
38
+ ts.forEachChild(node, visit)
39
+ }
40
+ visit(source)
41
+ }
42
+ reachedByProgram.set(program, reached)
43
+ return reached
44
+ }
45
+
46
+ /** A method is a webhook delivery intake only when a webhook door's call resolves to it. */
47
+ const isDoorIntake = (context, hfs, method) => {
48
+ const declaration = typed(context).toTs(method)
49
+ return !!declaration && doorCalledDeclarations(context, hfs).has(declaration)
50
+ }
51
+
52
+ /** Defaults keep the same first-parameter binding. */
53
+ const parameterOf = (parameter) => parameter?.type === "AssignmentPattern" ? parameter.left : parameter
54
+
55
+ /** The extra delivery contracts missed by the existing eventId-object rule. */
56
+ const deliveryKey = (context, method) => {
57
+ const first = parameterOf(method.value.params[0])
58
+ if (!first) return null
59
+ if (first.type === "Identifier" && DELIVERY_ID.test(first.name)) return { parameter: first }
60
+ const { checker, toTs } = typed(context)
61
+ const type = checker.getTypeAtLocation(toTs(first))
62
+ const parts = type.isUnion() ? type.types : [type]
63
+ const ids = [...new Set(parts.flatMap((part) => checker.getPropertiesOfType(part).map((property) => property.getName()).filter((name) => DELIVERY_ID.test(name))))]
64
+ if (ids.length) return { parameter: first, properties: ids }
65
+ const raw = first.type === "Identifier" && first.name === "rawBody"
66
+ && method.value.params.slice(1).some((parameter) => parameterOf(parameter)?.name === "signature")
67
+ || parts.some((part) => checker.getPropertyOfType(part, "rawBody") && checker.getPropertyOfType(part, "signature"))
68
+ // A declared verifier class proves authenticity; its ordinary domain intake owns dedupe.
69
+ const owner = method.parent?.parent
70
+ if (raw && !/(?:Verifier|verify)$/.test(owner?.id?.name ?? "")) return { parameter: first, raw: true }
71
+ return null
72
+ }
73
+
74
+ /** Identifiers are compared by lexical binding, so a same-spelled shadow is not a key. */
75
+ const sameBinding = (context, left, right) => left?.type === "Identifier" && right?.type === "Identifier"
76
+ && resolveVariable(context.sourceCode, left) !== null
77
+ && resolveVariable(context.sourceCode, left) === resolveVariable(context.sourceCode, right)
78
+
79
+ /** A decoded key comes from the raw input or a later delivery-id parameter, never a widened constant. */
80
+ const rawKeySource = (context, expression, key, seen = new Set()) => {
81
+ if (!expression || seen.has(expression)) return false
82
+ seen.add(expression)
83
+ let sourced = false
84
+ walk(expression, (node) => {
85
+ if (node.type !== "Identifier") return
86
+ if (sameBinding(context, node, key.parameter)) sourced = true
87
+ const variable = resolveVariable(context.sourceCode, node)
88
+ for (const definition of variable?.defs ?? []) {
89
+ if (definition.type === "Parameter" && (DELIVERY_ID.test(node.name)
90
+ || node.parent?.type === "MemberExpression" && node.parent.object === node
91
+ && DELIVERY_ID.test(node.parent.computed ? node.parent.property.value : node.parent.property.name))) sourced = true
92
+ if (definition.type === "Variable" && rawKeySource(context, definition.node.init, key, seen)) sourced = true
93
+ }
94
+ }, { intoFunctions: false })
95
+ return sourced
96
+ }
97
+
98
+ /** Scalar ids and object ids must actually key the claim; a raw payload itself is never a delivery key. */
99
+ const usesKey = (context, argument, key) => {
100
+ if (!argument) return false
101
+ if (key.raw) {
102
+ if (sameBinding(context, argument, key.parameter) || argument.type === "Literal") return false
103
+ const name = argument.type === "Identifier" ? argument.name
104
+ : argument.type === "MemberExpression" ? argument.computed ? argument.property.value : argument.property.name : null
105
+ if (!name || !DELIVERY_ID.test(name)) return false
106
+ const { checker, toTs } = typed(context)
107
+ const type = checker.getTypeAtLocation(toTs(argument))
108
+ const parts = type.isUnion() ? type.types : [type]
109
+ return parts.every((part) => part.flags & ts.TypeFlags.StringLike && !(part.flags & ts.TypeFlags.StringLiteral))
110
+ && rawKeySource(context, argument, key)
111
+ }
112
+ if (key.parameter.type === "ObjectPattern") {
113
+ return key.parameter.properties.some((property) => property.type === "Property"
114
+ && key.properties?.includes(property.key.name ?? property.key.value)
115
+ && sameBinding(context, argument, parameterOf(property.value)))
116
+ }
117
+ if (!key.properties) return sameBinding(context, argument, key.parameter)
118
+ return argument.type === "MemberExpression" && !argument.optional
119
+ && sameBinding(context, argument.object, key.parameter)
120
+ && key.properties.includes(argument.computed ? argument.property.value : argument.property.name)
121
+ }
122
+
123
+ /** Find the first completed await, without treating a nested helper as work performed here. */
124
+ const firstAwait = (body) => {
125
+ let result = null
126
+ walk(body, (node) => {
127
+ if (node.type === "AwaitExpression" && (!result || node.range[1] < result.range[1])) result = node
128
+ }, { intoFunctions: false })
129
+ return result
130
+ }
131
+
132
+ /** A parameter's binding cannot be overwritten before the claim and still prove the delivered id. */
133
+ const preservesKey = (context, key, call) => {
134
+ const parameter = key.raw ? call.arguments[1] : key.parameter
135
+ if (parameter.type !== "Identifier") return true
136
+ const variable = resolveVariable(context.sourceCode, parameter)
137
+ return variable && !variable.references.some((reference) => reference.isWrite() && !reference.init
138
+ && reference.identifier.range[0] < call.range[1])
139
+ }
140
+
141
+ /** Scalar/DTO claims are the first statement; a raw intake may first bind its decoded key. */
142
+ const claimsBeforeWork = (context, body, statement, key, call) => {
143
+ const index = body.body.indexOf(statement)
144
+ if (index === 0) return true
145
+ if (index < 0 || !key.raw) return false
146
+ const dependencies = new Set()
147
+ const read = (expression) => {
148
+ if (!expression) return
149
+ walk(expression, (node) => {
150
+ if (node.type !== "Identifier") return
151
+ const variable = resolveVariable(context.sourceCode, node)
152
+ if (!variable || dependencies.has(variable)) return
153
+ dependencies.add(variable)
154
+ for (const definition of variable.defs) if (definition.type === "Variable") read(definition.node.init)
155
+ }, { intoFunctions: false })
156
+ }
157
+ read(call.arguments[1])
158
+ return body.body.slice(0, index).every((entry) => entry.type === "VariableDeclaration" && entry.kind === "const"
159
+ && entry.declarations.every((declaration) => declaration.id.type === "Identifier" && dependencies.has(resolveVariable(context.sourceCode, declaration.id))))
160
+ }
161
+
162
+ /** A claim cannot be made conditionally or after another operation in its own statement. */
163
+ const priorWork = (expression, call) => {
164
+ let found = false
165
+ walk(expression, (node) => {
166
+ if (node !== call && ["CallExpression", "NewExpression", "AssignmentExpression", "UpdateExpression", "ConditionalExpression", "LogicalExpression"].includes(node.type)
167
+ && node.range[1] < call.range[1]) found = true
168
+ }, { intoFunctions: false })
169
+ return found
170
+ }
171
+
172
+ /** Select TypeORM's actual inline transaction callback, never a later ignored callback. */
173
+ const claimBody = (context, body) => {
174
+ if (body.body.length !== 1) return { body, manager: null }
175
+ const statement = body.body[0]
176
+ const expression = statement.type === "ReturnStatement" ? statement.argument
177
+ : statement.type === "ExpressionStatement" && statement.expression.type === "AwaitExpression" ? statement.expression : null
178
+ const call = expression?.type === "AwaitExpression" ? expression.argument : expression
179
+ const callee = call?.callee
180
+ if (call?.type !== "CallExpression" || callee.type !== "MemberExpression" || callee.computed
181
+ || callee.property.name !== "transaction" || !isPackageType(context, callee.object, "EntityManager", "typeorm")
182
+ || priorWork(call, call)) return { body, manager: null }
183
+ const first = call.arguments[0]
184
+ const callback = call.arguments.length === 1 ? first
185
+ : call.arguments.length === 2 && first.type === "Literal"
186
+ && ["READ UNCOMMITTED", "READ COMMITTED", "REPEATABLE READ", "SERIALIZABLE"].includes(first.value) ? call.arguments[1] : null
187
+ if (!callback || !["ArrowFunctionExpression", "FunctionExpression"].includes(callback.type)
188
+ || !callback.async || callback.body.type !== "BlockStatement" || callback.params.length !== 1) return { body, manager: null }
189
+ const manager = callback.params[0]
190
+ return manager.type === "Identifier" && isPackageType(context, manager, "EntityManager", "typeorm")
191
+ ? { body: callback.body, manager } : { body, manager: null }
192
+ }
193
+
194
+ /** Match an immediate false refusal with no work before the return. */
195
+ const rejectsDuplicate = (context, awaited, body) => {
196
+ const rejects = (test, target) => test?.type === "UnaryExpression" && test.operator === "!" && (test.argument === target || sameBinding(context, test.argument, target))
197
+ || test?.type === "BinaryExpression" && test.operator === "==="
198
+ && ((test.left === target && test.right.type === "Literal" && test.right.value === false)
199
+ || (test.right === target && test.left.type === "Literal" && test.left.value === false))
200
+ const returns = (statement) => statement?.type === "ReturnStatement" && !statement.argument
201
+ || statement?.type === "BlockStatement" && statement.body.length === 1 && returns(statement.body[0])
202
+ const parent = awaited.parent
203
+ if (["UnaryExpression", "BinaryExpression"].includes(parent.type)) {
204
+ const branch = parent.parent
205
+ return body.body.includes(branch) && branch.type === "IfStatement" && rejects(branch.test, awaited) && returns(branch.consequent)
206
+ }
207
+ if (parent.type !== "VariableDeclarator" || parent.id.type !== "Identifier" || parent.init !== awaited) return false
208
+ const declaration = parent.parent
209
+ const index = body.body.indexOf(declaration)
210
+ const branch = body.body[index + 1]
211
+ return index !== -1 && declaration.declarations.length === 1
212
+ && branch?.type === "IfStatement" && rejects(branch.test, parent.id) && returns(branch.consequent)
213
+ }
214
+
215
+ /** A public service intake with a delivery key claims that same key first and refuses replays. */
216
+ export const webhookInboxKey = {
217
+ meta: {
218
+ type: "problem",
219
+ docs: { description: "Delivery methods with first *Id parameters or DTO properties, or raw-body signature contracts, claim the real Inbox with a stable delivery key before work and return on duplicates. Receipt transactions publish events and leave processing to a reactor/job." },
220
+ schema: [],
221
+ messages: {
222
+ key: "[BE_WEBHOOK_INBOX_KEY] This delivery intake must first await the real platform/inbox claim(source, deliveryId), using its first *Id parameter or DTO property, or a decoded id for a signed raw-body intake, and return immediately when the claim is false. A transaction claim uses that callback's own manager; raw payloads and fixed literals are not delivery keys.",
223
+ handoff: "[BE_WEBHOOK_INBOX_KEY] A receipt transaction records the inbox delivery and publishes the typed event. Remove the process callback and domain/command processing from this transaction; a reactor or job processes the delivery after commit.",
224
+ },
225
+ },
226
+ create(context) {
227
+ const hfs = hfsOf(context)
228
+ const slot = hfs.slotOf(context.filename)
229
+ if (!slot || slot.startsWith("be.tests.") || !baseName(context.filename).endsWith(".service.ts")) return {}
230
+ if (slot.startsWith("be.platform") && ["event-bus", "http-security"].includes(ownerNameOf(hfs, context.filename))) return {}
231
+ return {
232
+ MethodDefinition(method) {
233
+ if (method.kind !== "method" || method.static || method.key.type === "PrivateIdentifier" || !method.value.body
234
+ || ["private", "protected"].includes(method.accessibility)) return
235
+ // A service no webhook door reaches is an ordinary domain service, whatever its first parameter is called.
236
+ if (!isDoorIntake(context, hfs, method)) return
237
+ const key = deliveryKey(context, method)
238
+ if (!key) return
239
+ if (webhookIntakeProcessesDelivery(context, hfs, method)) context.report({ node: method.key, messageId: "handoff" })
240
+ const { body, manager } = claimBody(context, method.value.body)
241
+ const awaited = firstAwait(body)
242
+ const call = awaited?.argument
243
+ const parent = awaited?.parent
244
+ const statement = parent?.type === "VariableDeclarator" ? parent.parent
245
+ : ["UnaryExpression", "BinaryExpression"].includes(parent?.type) ? parent.parent : parent
246
+ const claims = call?.type === "CallExpression" && !call.optional
247
+ && call.callee.type === "MemberExpression" && !call.callee.computed && !call.callee.optional
248
+ && call.callee.property.name === "claim" && call.arguments.length >= 2
249
+ && isOwnedType(context, call.callee.object, { name: "Inbox", capability: "inbox", tier: "platform" })
250
+ && usesKey(context, call.arguments[1], key)
251
+ && preservesKey(context, key, call)
252
+ && (!manager || sameBinding(context, call.arguments[2], manager))
253
+ && claimsBeforeWork(context, body, statement, key, call)
254
+ && !priorWork(statement, call)
255
+ && rejectsDuplicate(context, awaited, body)
256
+ if (!claims) context.report({ node: method.key, messageId: "key" })
257
+ },
258
+ }
259
+ },
260
+ }
@@ -0,0 +1,140 @@
1
+ /** R263: a receipt transaction records and publishes; a reactor/job processes after commit. */
2
+ import ts from "typescript"
3
+ import { baseName, ownerNameOf } from "./ports.mjs"
4
+ import { originsOfType, typed } from "./types.mjs"
5
+
6
+ /** Imported aliases and overload signatures retain the implementation's actual body. */
7
+ const bodiesOf = (checker, call) => {
8
+ const expression = call.expression
9
+ let name = expression
10
+ if (ts.isPropertyAccessExpression(expression)) name = expression.name
11
+ if (ts.isElementAccessExpression(expression)) name = expression.argumentExpression
12
+ const symbol = checker.getSymbolAtLocation(name)
13
+ const target = symbol?.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol
14
+ const declarations = [checker.getResolvedSignature(call)?.declaration, ...(target?.getDeclarations() ?? [])]
15
+ return [...new Set(declarations.map((declaration) => declaration?.body ?? declaration?.initializer?.body).filter(Boolean))]
16
+ }
17
+
18
+ /** Receiver ownership is established by its declared type, including aliases and inherited ports; `accepts` also sees the type it came from. */
19
+ const hasOrigin = (checker, receiver, accepts, seen = new Set()) => {
20
+ const type = receiver && checker.getTypeAtLocation(receiver)
21
+ const read = (current) => {
22
+ if (!current || seen.has(current)) return false
23
+ seen.add(current)
24
+ if (originsOfType(checker, current).some((origin) => accepts(origin, current))) return true
25
+ const parts = current.isUnionOrIntersection() ? current.types : []
26
+ const bases = current.isClassOrInterface() ? checker.getBaseTypes(current) ?? [] : []
27
+ return [...parts, ...bases].some(read)
28
+ }
29
+ return read(type)
30
+ }
31
+
32
+ /** Bracket access with a literal key has the same meaning as a dotted method/property. */
33
+ const memberName = (expression) => {
34
+ if (ts.isPropertyAccessExpression(expression)) return expression.name.text
35
+ if (ts.isElementAccessExpression(expression) && ts.isStringLiteralLike(expression.argumentExpression)) return expression.argumentExpression.text
36
+ return null
37
+ }
38
+
39
+ /** A callback named process is the former in-transaction processing contract, including aliases and methods. */
40
+ const isProcessCallback = (checker, node) => {
41
+ if (!(ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node) || ts.isMethodDeclaration(node))) return false
42
+ const name = ts.isComputedPropertyName(node.name) ? node.name.expression : node.name
43
+ if (!(ts.isIdentifier(name) || ts.isStringLiteralLike(name)) || name.text !== "process") return false
44
+ return !!node.body || checker.getTypeAtLocation(node.initializer ?? node.name).getCallSignatures().length > 0
45
+ }
46
+
47
+ /** Persisting receipt data and publishing through platform ports are allowed; domain/CQRS processing is deferred. */
48
+ const isBusinessOrigin = (hfs, origin) => {
49
+ if (origin.module === "@nestjs/cqrs") return ["CommandBus", "QueryBus"].includes(origin.name)
50
+ return origin.module === null && hfs.tierOf(origin.file) === "domain" && baseName(origin.file).endsWith(".service.ts")
51
+ }
52
+
53
+ /**
54
+ * A domain receipt port records a delivery and processes nothing: a class with no base class whose constructor takes only platform
55
+ * ports (and TypeORM), at least one of them the platform `Inbox`. It cannot reach another domain service or the CQRS buses, so a call
56
+ * on it inside the receipt transaction is the inbox record itself (owner ruling 2026-10-17 "Theo y tro": the owner delegated the decision to main).
57
+ */
58
+ const isReceiptPort = (checker, hfs, type) => {
59
+ const declaration = type.getSymbol()?.getDeclarations()?.find(ts.isClassDeclaration)
60
+ const constructor = declaration && !declaration.heritageClauses ? declaration.members.find(ts.isConstructorDeclaration) : undefined
61
+ if (!constructor || constructor.parameters.length === 0) return false
62
+ const dependencies = constructor.parameters.map((parameter) => originsOfType(checker, checker.getTypeAtLocation(parameter)))
63
+ const isPort = (origin) => origin.module === "typeorm" || (origin.module === null && hfs.tierOf(origin.file) === "platform")
64
+ const isInbox = (origin) => origin.module === null && origin.name === "Inbox" && hfs.tierOf(origin.file) === "platform" && ownerNameOf(hfs, origin.file) === "inbox"
65
+ return dependencies.every((origins) => origins.length > 0 && origins.every(isPort)) && dependencies.some((origins) => origins.some(isInbox))
66
+ }
67
+
68
+ /** The transaction port's package establishes when the callback runs before commit. */
69
+ const isTransactionCall = (checker, call, receiver) => memberName(call.expression) === "transaction"
70
+ && hasOrigin(checker, receiver, (origin) => origin.module === "typeorm" && ["EntityManager", "DataSource"].includes(origin.name))
71
+
72
+ /** Extracting, binding or destructuring a method preserves its domain/bus receiver. */
73
+ const isBusinessCall = (checker, hfs, expression, seen = new Set()) => {
74
+ if (!expression || seen.has(expression)) return false
75
+ seen.add(expression)
76
+ const accepts = (origin, type) => isBusinessOrigin(hfs, origin) && !isReceiptPort(checker, hfs, type)
77
+ if (ts.isParenthesizedExpression(expression) || ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression)) return isBusinessCall(checker, hfs, expression.expression, seen)
78
+ if (ts.isPropertyAccessExpression(expression) || ts.isElementAccessExpression(expression)) {
79
+ if (hasOrigin(checker, expression.expression, accepts)) return true
80
+ if (["bind", "call", "apply"].includes(memberName(expression))) return isBusinessCall(checker, hfs, expression.expression, seen)
81
+ }
82
+ if (ts.isCallExpression(expression)) return isBusinessCall(checker, hfs, expression.expression, seen)
83
+ const name = ts.isPropertyAccessExpression(expression) ? expression.name
84
+ : ts.isElementAccessExpression(expression) ? expression.argumentExpression : expression
85
+ const symbol = checker.getSymbolAtLocation(name)
86
+ const target = symbol?.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(symbol) : symbol
87
+ return target?.getDeclarations()?.some((declaration) => {
88
+ if (ts.isBindingElement(declaration)) return hasOrigin(checker, declaration.parent.parent.initializer, accepts)
89
+ return isBusinessCall(checker, hfs, declaration.initializer, seen)
90
+ }) ?? false
91
+ }
92
+
93
+ /** Passing a const callback into transaction does not move its work past commit. */
94
+ const callbackBodies = (checker, expression, seen = new Set()) => {
95
+ if (!expression || seen.has(expression)) return []
96
+ seen.add(expression)
97
+ if (expression.body) return [expression.body]
98
+ if (ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression) || ts.isParenthesizedExpression(expression)) return callbackBodies(checker, expression.expression, seen)
99
+ if (ts.isCallExpression(expression) && memberName(expression.expression) === "bind") return callbackBodies(checker, expression.expression.expression, seen)
100
+ const reference = ts.isPropertyAccessExpression(expression) ? expression.name : expression
101
+ const symbol = checker.getSymbolAtLocation(reference)
102
+ return symbol?.getDeclarations()?.flatMap((declaration) => declaration.body ? [declaration.body] : callbackBodies(checker, declaration.initializer, seen)) ?? []
103
+ }
104
+
105
+ /** Old inline process callbacks and business dispatch inside the receipt transaction violate handoff. */
106
+ const processesDelivery = (checker, hfs, body) => {
107
+ let found = false
108
+ const visited = [new Set(), new Set()]
109
+ const visit = (node, inTransaction = false) => {
110
+ const nodes = visited[Number(inTransaction)]
111
+ if (found || nodes.has(node)) return
112
+ nodes.add(node)
113
+ if (isProcessCallback(checker, node)) found = true
114
+ if (ts.isCallExpression(node)) {
115
+ if (inTransaction && isBusinessCall(checker, hfs, node.expression)) found = true
116
+ const member = ts.isPropertyAccessExpression(node.expression) || ts.isElementAccessExpression(node.expression)
117
+ if (member && isTransactionCall(checker, node, node.expression.expression)) {
118
+ for (const argument of node.arguments) {
119
+ visit(argument, true)
120
+ for (const callback of callbackBodies(checker, argument)) visit(callback, true)
121
+ }
122
+ return
123
+ }
124
+ if (inTransaction) for (const callback of callbackBodies(checker, node.expression)) visit(callback, true)
125
+ }
126
+ ts.forEachChild(node, (child) => visit(child, inTransaction))
127
+ }
128
+ visit(body)
129
+ return found
130
+ }
131
+
132
+ /** Inspect the linted intake method or the implementation resolved at a webhook door's domain call. */
133
+ export const webhookIntakeProcessesDelivery = (context, hfs, node) => {
134
+ const { checker, toTs } = typed(context)
135
+ const reference = toTs(node)
136
+ if (!reference) return false
137
+ let bodies = reference.body ? [reference.body] : []
138
+ if (ts.isCallExpression(reference)) bodies = [reference, ...bodiesOf(checker, reference)]
139
+ return bodies.some((body) => processesDelivery(checker, hfs, body))
140
+ }
package/lib/why.mjs ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The catalogue sub-codes the back-end laws publish: `{ <rule id>: { code } }`.
3
+ * A rule's own code comes from its enforcer row in knowledge/hfs/rules.yaml; a SECOND code a rule emits
4
+ * (one rule, several failure codes) is named here so scripts/checks/check-hfs-rules.mjs sees it as emitted.
5
+ * Append-only: each alpha.15 batch adds its own secondary codes at the end (union-merged).
6
+ */
7
+ export const why = {
8
+ "webhook-inbox-key": { code: "BE_WEBHOOK_INBOX_KEY" },
9
+ "rest-route-declared": { code: "BE_REST_ROUTE_UNDECLARED" },
10
+ "security-headers": { code: "BE_SECURITY_HEADERS" },
11
+ "trust-proxy": { code: "BE_TRUST_PROXY" },
12
+ "health-signals": { code: "BE_HEALTH_SIGNALS" },
13
+ }