@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.
- package/CHANGELOG.md +15 -0
- package/README.md +2 -2
- package/app-composition.mjs +348 -3
- package/authorization.mjs +68 -2
- package/builders.mjs +2 -2
- package/config-owner.mjs +44 -15
- package/default-deny.mjs +80 -9
- package/edge-hardening.mjs +139 -0
- package/feature-thin.mjs +78 -2
- package/function-budget.mjs +49 -0
- package/health-ready.mjs +539 -0
- package/index.mjs +37 -0
- package/infra-owner.mjs +3 -0
- package/jwt-alg.mjs +45 -0
- package/lib/subject-values.mjs +55 -0
- package/lib/unit-spec.mjs +0 -3
- package/lib/webhook-handoff-inbox.mjs +260 -0
- package/lib/webhook-handoff-intake.mjs +140 -0
- package/lib/why.mjs +13 -0
- package/module-shape.mjs +147 -51
- package/operations.mjs +165 -1
- package/package.json +1 -1
- package/realtime.mjs +59 -22
- package/resilience.mjs +45 -14
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
- package/runtime/knowledge/hfs/rules.yaml +3401 -152
- package/runtime/knowledge/hfs/slots.yaml +344 -62
- package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/models/runtimes.yaml +1 -1
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
- package/runtime/scripts/api/git/lib.mjs +3 -0
- package/runtime/scripts/api/git/merge-base.mjs +9 -0
- package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
- package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
- package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
- package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
- package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
- package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
- package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
- package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
- package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
- package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
- package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +3 -3
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
- package/runtime/scripts/hfs/edition-slots.mjs +2 -2
- package/runtime/scripts/hfs/manifest-shape.mjs +67 -7
- package/runtime/scripts/hfs/project-rule.mjs +3 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
- package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/slot-imports.mjs +18 -5
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/lib/env.mjs +20 -0
- package/runtime/scripts/lib/event-contract.mjs +107 -0
- package/runtime/scripts/lib/json-clone.mjs +61 -0
- package/runtime/scripts/lib/list.mjs +4 -0
- package/runtime/scripts/lib/secret-patterns.mjs +60 -5
- package/runtime/scripts/lib/stack-services.mjs +1 -1
- package/runtime/scripts/lib/ts-analysis.mjs +5 -2
- package/runtime/scripts/lib/ts-ast.mjs +27 -2
- package/service-isolation.mjs +3 -0
- package/size-budget.mjs +9 -5
- package/third-party-owner.mjs +149 -0
- package/transport.mjs +11 -29
- package/unit-spec.mjs +270 -84
- 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
|
+
}
|