@starci/eslint-canon-be 1.2.2 → 1.7.1
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 +47 -0
- package/README.md +46 -7
- package/async-discipline.mjs +83 -0
- package/authorization.mjs +106 -106
- package/comments.mjs +7 -55
- package/config-owner.mjs +252 -0
- package/cqrs.mjs +7 -141
- package/data-access.mjs +151 -5
- package/default-deny.mjs +116 -0
- package/e2e-flow.mjs +222 -222
- package/error-handling.mjs +207 -0
- package/idempotency.mjs +101 -0
- package/index.mjs +34 -8
- package/input-bounds.mjs +84 -0
- package/lib/ast.mjs +81 -0
- package/lib/config.mjs +49 -0
- package/lib/path.mjs +15 -0
- package/lib/slots.mjs +83 -0
- package/log-safety.mjs +148 -0
- package/module-layering.mjs +87 -105
- package/module-shape.mjs +202 -0
- package/observability.mjs +275 -275
- package/package.json +4 -1
- package/query-safety.mjs +274 -0
- package/resilience.mjs +178 -0
- package/schema-authority.mjs +187 -0
- package/size-budget.mjs +106 -0
- package/spec-quality.mjs +132 -0
- package/suppression.mjs +60 -0
- package/temporal.mjs +71 -0
- package/testing.mjs +31 -19
- package/transport.mjs +168 -168
- package/type-safety.mjs +98 -57
- package/user-copy.mjs +106 -0
- package/cdc.mjs +0 -150
- package/event-delivery.mjs +0 -93
- package/exception-identity.mjs +0 -200
- package/exceptions.mjs +0 -262
package/default-deny.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold the transport half of HFS default-deny (catalog R41 `BE_DEFAULT_DENY`).
|
|
3
|
+
*
|
|
4
|
+
* Every route needs a signed-in principal unless it says otherwise, and every body has a type:
|
|
5
|
+
*
|
|
6
|
+
* - `no-untyped-body` refuses a `@Body()` (or `@Args()`) parameter with no type, `unknown`, `any`, `object`,
|
|
7
|
+
* `{}` or `Record<string, unknown>`; it refuses `GraphQLJSON` as a tunnel around the schema; and it refuses
|
|
8
|
+
* a `switch (input.operation)` in a transport file, because each operation is its own typed operation.
|
|
9
|
+
* - `public-needs-reason` refuses `@Public()` without `{ reason: "<why>" }`. An open door states why it is
|
|
10
|
+
* open, so the list of open doors can be read and reviewed.
|
|
11
|
+
*
|
|
12
|
+
* The `APP_GUARD` registration and the app-level guard live in the composition check, not here: a rule reading
|
|
13
|
+
* one file cannot see which module the app registered.
|
|
14
|
+
*/
|
|
15
|
+
import { decoratorName, keyName, staticText } from "./lib/ast.mjs"
|
|
16
|
+
import { isDeclarationFile, normalizePath } from "./lib/path.mjs"
|
|
17
|
+
|
|
18
|
+
const BODY_DECORATORS = new Set(["Body", "Args"])
|
|
19
|
+
|
|
20
|
+
const isUntyped = (annotation) => {
|
|
21
|
+
if (!annotation) return true
|
|
22
|
+
const type = annotation.typeAnnotation
|
|
23
|
+
if (!type) return true
|
|
24
|
+
if (["TSUnknownKeyword", "TSAnyKeyword", "TSObjectKeyword"].includes(type.type)) return true
|
|
25
|
+
if (type.type === "TSTypeLiteral" && type.members.length === 0) return true
|
|
26
|
+
if (type.type === "TSTypeReference" && type.typeName.type === "Identifier" && type.typeName.name === "Record") {
|
|
27
|
+
const value = type.typeArguments?.params?.[1] ?? type.typeParameters?.params?.[1]
|
|
28
|
+
return value?.type === "TSUnknownKeyword" || value?.type === "TSAnyKeyword"
|
|
29
|
+
}
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A body or argument carries a declared type, never `unknown`, `any` or GraphQLJSON. */
|
|
34
|
+
export const noUntypedBody = {
|
|
35
|
+
meta: {
|
|
36
|
+
type: "problem",
|
|
37
|
+
docs: { description: "`@Body()` and `@Args()` parameters are typed; no GraphQLJSON tunnel; no `switch (input.operation)`." },
|
|
38
|
+
schema: [],
|
|
39
|
+
messages: {
|
|
40
|
+
untyped: "This `@{{decorator}}()` parameter has no useful type. Declare a DTO class or contract type so the framework validates the input.",
|
|
41
|
+
json: "`GraphQLJSON` tunnels untyped data through the schema. Declare an input type with the fields the operation needs.",
|
|
42
|
+
operationSwitch: "`switch` on an `operation` field dispatches several operations through one untyped door. Make each operation its own typed resolver or controller method.",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
create(context) {
|
|
46
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
47
|
+
if (isDeclarationFile(filename)) return {}
|
|
48
|
+
const inTransport = filename.includes("/transport/")
|
|
49
|
+
return {
|
|
50
|
+
Identifier(node) {
|
|
51
|
+
if (node.name === "GraphQLJSON" && !/^Import.*Specifier$/.test(node.parent?.type ?? "")) {
|
|
52
|
+
context.report({ node, messageId: "json" })
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
SwitchStatement(node) {
|
|
56
|
+
const discriminant = node.discriminant
|
|
57
|
+
if (inTransport && discriminant.type === "MemberExpression" && keyName(discriminant.property) === "operation") {
|
|
58
|
+
context.report({ node, messageId: "operationSwitch" })
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
Decorator(node) {
|
|
62
|
+
const name = decoratorName(node)
|
|
63
|
+
if (!BODY_DECORATORS.has(name)) return
|
|
64
|
+
const parameter = node.parent
|
|
65
|
+
if (!parameter) return
|
|
66
|
+
const bound = parameter.type === "TSParameterProperty" ? parameter.parameter : parameter
|
|
67
|
+
if (bound.type !== "Identifier" && bound.type !== "ObjectPattern" && bound.type !== "AssignmentPattern") return
|
|
68
|
+
const annotated = bound.type === "AssignmentPattern" ? bound.left : bound
|
|
69
|
+
if (isUntyped(annotated.typeAnnotation)) {
|
|
70
|
+
context.report({ node, messageId: "untyped", data: { decorator: name } })
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** An open door states why it is open. */
|
|
78
|
+
export const publicNeedsReason = {
|
|
79
|
+
meta: {
|
|
80
|
+
type: "problem",
|
|
81
|
+
docs: { description: "`@Public()` carries `{ reason: \"<why>\" }`." },
|
|
82
|
+
schema: [],
|
|
83
|
+
messages: {
|
|
84
|
+
reason: "`@Public()` has no reason. Write `@Public({ reason: \"<why this operation is open>\" })` so the open doors can be reviewed.",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
create(context) {
|
|
88
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
89
|
+
if (isDeclarationFile(filename)) return {}
|
|
90
|
+
return {
|
|
91
|
+
Decorator(node) {
|
|
92
|
+
if (decoratorName(node) !== "Public") return
|
|
93
|
+
const expression = node.expression
|
|
94
|
+
const argument = expression.type === "CallExpression" ? expression.arguments[0] : undefined
|
|
95
|
+
let reason = null
|
|
96
|
+
if (argument?.type === "ObjectExpression") {
|
|
97
|
+
const property = argument.properties.find((item) => item.type === "Property" && keyName(item.key) === "reason")
|
|
98
|
+
reason = property ? staticText(property.value) : null
|
|
99
|
+
}
|
|
100
|
+
if (reason === null || reason.trim() === "") context.report({ node, messageId: "reason" })
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The rules this law contributes to the plugin. */
|
|
107
|
+
export const rules = {
|
|
108
|
+
"no-untyped-body": noUntypedBody,
|
|
109
|
+
"public-needs-reason": publicNeedsReason,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Both start at error: an untyped door and an unexplained open door are the failures this law exists for. */
|
|
113
|
+
export const recommended = {
|
|
114
|
+
"starci-be/no-untyped-body": "error",
|
|
115
|
+
"starci-be/public-needs-reason": "error",
|
|
116
|
+
}
|
package/e2e-flow.mjs
CHANGED
|
@@ -1,222 +1,222 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The rules that hold `e2e-flow.md`.
|
|
3
|
-
*
|
|
4
|
-
* FIVE OF THE TWELVE RULES HAVE A MACHINE-CHECKABLE HALF HERE. That is the honest number rather
|
|
5
|
-
* than a gap somebody should close. A rule earns its place by being exact: it must fire on a syntactic shape, not on a
|
|
6
|
-
* judgement, or it becomes something authors learn to disable and the law is worse off than when
|
|
7
|
-
* nothing enforced it.
|
|
8
|
-
*
|
|
9
|
-
* What was measured and left alone, so the next reader does not "finish the job":
|
|
10
|
-
*
|
|
11
|
-
* - E2E-1 (one file, one flow, named for the sentence). A filename cannot be checked against a
|
|
12
|
-
* business sentence. `course-purchase.e2e-spec.ts` and `resolver-group.e2e-spec.ts` are the same
|
|
13
|
-
* shape to a parser and opposite things to a reader.
|
|
14
|
-
* - E2E-2 (named steps, not one long case). Counting `it` blocks would refuse a flow that is
|
|
15
|
-
* genuinely one step, and a rule whose first false positive is legitimate teaches authors that
|
|
16
|
-
* the rule is wrong rather than that they are.
|
|
17
|
-
* - E2E-5, E2E-6 and E2E-9 turn on WHAT is asserted or WHO is acting, which is meaning. E2E-6 in
|
|
18
|
-
* particular has no shape to deny: absence is proved through a stored row, a socket silence
|
|
19
|
-
* window, or a rejected promise depending on the flow, and a rule narrow enough to accept all
|
|
20
|
-
* three accepts everything. E2E-9's ban on a shared actor is a fact about TWO files at once,
|
|
21
|
-
* which one file's AST cannot see.
|
|
22
|
-
* - E2E-8 (one place stands the world up) is STILL a fact about a repository's fixtures for its
|
|
23
|
-
* first half, not about one file, so THAT half belongs to a gate that can see the tree. Its
|
|
24
|
-
* second half - "a spec file contains no wiring of its own" - is a per-file shape after all, the
|
|
25
|
-
* same kind of shape `e2e-uses-production-transport` already refuses for buses and workers, and
|
|
26
|
-
* is added below.
|
|
27
|
-
* - E2E-10 (a flow logs nothing) is already held: `no-console` and `no-framework-logger` in the
|
|
28
|
-
* observability law cover every call site, and a second rule saying the same thing in this lane
|
|
29
|
-
* would double every report. It stays undeclared here on purpose, not by oversight.
|
|
30
|
-
*
|
|
31
|
-
* The rules below enforce only exact syntax: transport bypass, wiring built inline instead of
|
|
32
|
-
* through the shared world, absence of any persisted-state read, direct provider imports, sleeps and
|
|
33
|
-
* branches. They do not pretend to judge business meaning.
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
/** Files this law governs. A flow is a named lane, not every file that happens to touch a database. */
|
|
37
|
-
const isE2eSpec = (filename) => /\.e2e-spec\.ts$/.test(String(filename || "").replace(/\\/g, "/"))
|
|
38
|
-
|
|
39
|
-
/** Names a repository sleeps by. Awaiting any of them is waiting for a duration instead of a state. */
|
|
40
|
-
const SLEEPERS = new Set(["sleep", "delay", "wait", "pause", "setTimeout"])
|
|
41
|
-
|
|
42
|
-
/** E2E enters through production transport and never invokes an internal actor directly. */
|
|
43
|
-
export const e2eUsesProductionTransport = {
|
|
44
|
-
meta: {
|
|
45
|
-
type: "problem",
|
|
46
|
-
docs: { description: "Operational E2E keeps the production transport boundary in the proof." },
|
|
47
|
-
schema: [],
|
|
48
|
-
messages: {
|
|
49
|
-
busImport: "`{{name}}` is an internal dispatcher. Enter through GraphQL, HTTP, socket, broker or scheduler.",
|
|
50
|
-
direct: "Direct `.{{method}}()` skips production routing and delivery semantics.",
|
|
51
|
-
actor: "Calling `{{name}}` directly bypasses the broker/CQRS boundary that owns it.",
|
|
52
|
-
},
|
|
53
|
-
},
|
|
54
|
-
create(context) {
|
|
55
|
-
if (!isE2eSpec(context.filename || context.getFilename())) return {}
|
|
56
|
-
const buses = new Set(["CommandBus", "QueryBus", "EventBus"])
|
|
57
|
-
return {
|
|
58
|
-
ImportDeclaration(node) {
|
|
59
|
-
if (node.source.value !== "@nestjs/cqrs") return
|
|
60
|
-
for (const specifier of node.specifiers) {
|
|
61
|
-
if (specifier.type !== "ImportSpecifier") continue
|
|
62
|
-
const imported = specifier.imported.name || specifier.imported.value
|
|
63
|
-
if (buses.has(imported)) context.report({ node: specifier, messageId: "busImport", data: { name: imported } })
|
|
64
|
-
}
|
|
65
|
-
},
|
|
66
|
-
CallExpression(node) {
|
|
67
|
-
const callee = node.callee
|
|
68
|
-
if (callee.type !== "MemberExpression" || callee.computed) return
|
|
69
|
-
const method = callee.property.name
|
|
70
|
-
if (method === "execute" || method === "process") {
|
|
71
|
-
context.report({ node: callee.property, messageId: "direct", data: { method } })
|
|
72
|
-
return
|
|
73
|
-
}
|
|
74
|
-
let receiver = callee.object
|
|
75
|
-
while (receiver && ["TSAsExpression", "TSTypeAssertion", "ChainExpression"].includes(receiver.type)) receiver = receiver.expression
|
|
76
|
-
if (receiver && receiver.type === "Identifier" && /(?:Worker|Handler)$/.test(receiver.name)) {
|
|
77
|
-
context.report({ node: callee.property, messageId: "actor", data: { name: receiver.name } })
|
|
78
|
-
}
|
|
79
|
-
},
|
|
80
|
-
}
|
|
81
|
-
},
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// -- E2E-3 ----------------------------------------------------------------------------------------
|
|
85
|
-
|
|
86
|
-
/** A flow polls until the state settles; it never waits for a duration. */
|
|
87
|
-
export const noSleepInFlow = {
|
|
88
|
-
meta: {
|
|
89
|
-
type: "problem",
|
|
90
|
-
docs: { description: "A flow polls for the state it needs, with a deadline. It never sleeps." },
|
|
91
|
-
schema: [],
|
|
92
|
-
messages: {
|
|
93
|
-
sleep:
|
|
94
|
-
"`{{name}}` waits for a DURATION, and a duration is a guess about somebody else's machine. It is too long on the machine that passes - every run pays it - and too short on the one that matters, where it fails as a flake nobody can reproduce. Poll for the state you are actually waiting for, with a deadline, so the test says what it wanted rather than how long it was willing to sit there.",
|
|
95
|
-
timer:
|
|
96
|
-
"A promise around `setTimeout` is a sleep with the name taken off. The objection is the same: nothing here says which state the flow is waiting for, so the failure message when it expires names a timeout instead of the thing that never happened. Poll the state, with a deadline.",
|
|
97
|
-
},
|
|
98
|
-
},
|
|
99
|
-
create(context) {
|
|
100
|
-
if (!isE2eSpec(context.filename || context.getFilename())) return {}
|
|
101
|
-
return {
|
|
102
|
-
CallExpression(node) {
|
|
103
|
-
const callee = node.callee
|
|
104
|
-
if (!callee || callee.type !== "Identifier" || !SLEEPERS.has(callee.name)) return
|
|
105
|
-
// A `setTimeout` wrapped in `new Promise` is reported once, as `timer`, by the branch below.
|
|
106
|
-
// Reporting it here as well would put two findings on one sleep and teach nobody anything.
|
|
107
|
-
for (let current = node.parent; current; current = current.parent) {
|
|
108
|
-
if (current.type === "NewExpression" && current.callee && current.callee.name === "Promise") return
|
|
109
|
-
}
|
|
110
|
-
context.report({ node, messageId: "sleep", data: { name: callee.name } })
|
|
111
|
-
},
|
|
112
|
-
NewExpression(node) {
|
|
113
|
-
if (!node.callee || node.callee.name !== "Promise") return
|
|
114
|
-
const source = context.sourceCode || context.getSourceCode()
|
|
115
|
-
if (/setTimeout/.test(source.getText(node))) context.report({ node, messageId: "timer" })
|
|
116
|
-
},
|
|
117
|
-
}
|
|
118
|
-
},
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// -- E2E-7 ----------------------------------------------------------------------------------------
|
|
122
|
-
|
|
123
|
-
/** A step asserts one outcome. A branch means the test is prepared for either, which is no assertion. */
|
|
124
|
-
export const noBranchInFlowStep = {
|
|
125
|
-
meta: {
|
|
126
|
-
type: "problem",
|
|
127
|
-
docs: { description: "A flow step takes no branch: it asserts the one outcome the business promises." },
|
|
128
|
-
schema: [],
|
|
129
|
-
messages: {
|
|
130
|
-
branch:
|
|
131
|
-
"A branch inside a step means this test passes down either path, so a green run stops being evidence that the business worked - it only proves the code reached the end. The flow knows which outcome it is asserting: state it. If two outcomes are both legitimate, they are two steps, or two flows.",
|
|
132
|
-
},
|
|
133
|
-
},
|
|
134
|
-
create(context) {
|
|
135
|
-
if (!isE2eSpec(context.filename || context.getFilename())) return {}
|
|
136
|
-
const source = context.sourceCode || context.getSourceCode()
|
|
137
|
-
|
|
138
|
-
/** True when this node sits inside the callback of an `it(...)`. */
|
|
139
|
-
const insideStep = (node) => {
|
|
140
|
-
for (let current = node.parent; current; current = current.parent) {
|
|
141
|
-
if (current.type !== "CallExpression") continue
|
|
142
|
-
const callee = current.callee
|
|
143
|
-
const name = callee && (callee.name || (callee.object && callee.object.name))
|
|
144
|
-
if (name === "it" || name === "test") return true
|
|
145
|
-
}
|
|
146
|
-
return false
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const report = (node) => {
|
|
150
|
-
if (insideStep(node)) context.report({ node, messageId: "branch" })
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
return {
|
|
154
|
-
IfStatement: report,
|
|
155
|
-
ConditionalExpression: report,
|
|
156
|
-
SwitchStatement: report,
|
|
157
|
-
LogicalExpression(node) {
|
|
158
|
-
// `a && b` used as a STATEMENT is a hidden if; the same operator inside an assertion is not
|
|
159
|
-
if (node.parent && node.parent.type === "ExpressionStatement") report(node)
|
|
160
|
-
// touching the source keeps the helper honest if the shape ever changes
|
|
161
|
-
void source
|
|
162
|
-
},
|
|
163
|
-
}
|
|
164
|
-
},
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// -- E2E-8 (the per-file half: a spec builds no wiring of its own) --------------------------------
|
|
168
|
-
|
|
169
|
-
/** A flow boots through the shared world; it does not build its own copy of the app. */
|
|
170
|
-
export const noWiringInFlowSpec = {
|
|
171
|
-
meta: {
|
|
172
|
-
type: "problem",
|
|
173
|
-
docs: {
|
|
174
|
-
description:
|
|
175
|
-
"An e2e spec boots through the shared world helper and builds no testing module of its own (E2E-8).",
|
|
176
|
-
},
|
|
177
|
-
schema: [],
|
|
178
|
-
messages: {
|
|
179
|
-
wiring:
|
|
180
|
-
"`Test.createTestingModule(...)` builds this flow's own copy of the app instead of booting through the shared world helper. E2E-8: the world is stood up in one place, and a spec file carries no wiring of its own - when the wiring changes, every flow that inlined its own module has to change with it, and two flows standing the world up slightly differently is how nobody learns where they differ. Boot through the shared helper and override only the one token this flow needs.",
|
|
181
|
-
},
|
|
182
|
-
},
|
|
183
|
-
create(context) {
|
|
184
|
-
if (!isE2eSpec(context.filename || context.getFilename())) return {}
|
|
185
|
-
return {
|
|
186
|
-
CallExpression(node) {
|
|
187
|
-
const callee = node.callee
|
|
188
|
-
if (!callee || callee.type !== "MemberExpression" || callee.computed) return
|
|
189
|
-
if (callee.object.type !== "Identifier" || callee.object.name !== "Test") return
|
|
190
|
-
if (callee.property.name !== "createTestingModule") return
|
|
191
|
-
context.report({ node: callee, messageId: "wiring" })
|
|
192
|
-
},
|
|
193
|
-
}
|
|
194
|
-
},
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
/** The rules this law contributes to the plugin. */
|
|
198
|
-
export const rules = {
|
|
199
|
-
"e2e-uses-production-transport": e2eUsesProductionTransport,
|
|
200
|
-
"no-sleep-in-flow": noSleepInFlow,
|
|
201
|
-
"no-branch-in-flow-step": noBranchInFlowStep,
|
|
202
|
-
"no-wiring-in-flow-spec": noWiringInFlowSpec,
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* The level this law asks for, as the plugin's own opinion.
|
|
207
|
-
*
|
|
208
|
-
* The first three are exact and fire on a syntactic shape rather than on a judgement, so none of
|
|
209
|
-
* them carries the false-positive risk that would justify adopting it at `warn`.
|
|
210
|
-
*
|
|
211
|
-
* `no-wiring-in-flow-spec` is exact in the SAME sense - `Test.createTestingModule(...)` is a fixed
|
|
212
|
-
* shape, not a guess - but it was measured at 60 of 77 e2e files in the reference repository
|
|
213
|
-
* building their own testing module rather than calling `bootFlowWorld`. That is real, pre-existing
|
|
214
|
-
* debt this task does not fix, so the rule lands at `warn` with the count rather than `error`,
|
|
215
|
-
* exactly like a rule that has not yet been burned down.
|
|
216
|
-
*/
|
|
217
|
-
export const recommended = {
|
|
218
|
-
"starci-be/e2e-uses-production-transport": "error",
|
|
219
|
-
"starci-be/no-sleep-in-flow": "error",
|
|
220
|
-
"starci-be/no-branch-in-flow-step": "error",
|
|
221
|
-
"starci-be/no-wiring-in-flow-spec": "warn", // no=60 of 77 - pre-existing, not burned down here
|
|
222
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `e2e-flow.md`.
|
|
3
|
+
*
|
|
4
|
+
* FIVE OF THE TWELVE RULES HAVE A MACHINE-CHECKABLE HALF HERE. That is the honest number rather
|
|
5
|
+
* than a gap somebody should close. A rule earns its place by being exact: it must fire on a syntactic shape, not on a
|
|
6
|
+
* judgement, or it becomes something authors learn to disable and the law is worse off than when
|
|
7
|
+
* nothing enforced it.
|
|
8
|
+
*
|
|
9
|
+
* What was measured and left alone, so the next reader does not "finish the job":
|
|
10
|
+
*
|
|
11
|
+
* - E2E-1 (one file, one flow, named for the sentence). A filename cannot be checked against a
|
|
12
|
+
* business sentence. `course-purchase.e2e-spec.ts` and `resolver-group.e2e-spec.ts` are the same
|
|
13
|
+
* shape to a parser and opposite things to a reader.
|
|
14
|
+
* - E2E-2 (named steps, not one long case). Counting `it` blocks would refuse a flow that is
|
|
15
|
+
* genuinely one step, and a rule whose first false positive is legitimate teaches authors that
|
|
16
|
+
* the rule is wrong rather than that they are.
|
|
17
|
+
* - E2E-5, E2E-6 and E2E-9 turn on WHAT is asserted or WHO is acting, which is meaning. E2E-6 in
|
|
18
|
+
* particular has no shape to deny: absence is proved through a stored row, a socket silence
|
|
19
|
+
* window, or a rejected promise depending on the flow, and a rule narrow enough to accept all
|
|
20
|
+
* three accepts everything. E2E-9's ban on a shared actor is a fact about TWO files at once,
|
|
21
|
+
* which one file's AST cannot see.
|
|
22
|
+
* - E2E-8 (one place stands the world up) is STILL a fact about a repository's fixtures for its
|
|
23
|
+
* first half, not about one file, so THAT half belongs to a gate that can see the tree. Its
|
|
24
|
+
* second half - "a spec file contains no wiring of its own" - is a per-file shape after all, the
|
|
25
|
+
* same kind of shape `e2e-uses-production-transport` already refuses for buses and workers, and
|
|
26
|
+
* is added below.
|
|
27
|
+
* - E2E-10 (a flow logs nothing) is already held: `no-console` and `no-framework-logger` in the
|
|
28
|
+
* observability law cover every call site, and a second rule saying the same thing in this lane
|
|
29
|
+
* would double every report. It stays undeclared here on purpose, not by oversight.
|
|
30
|
+
*
|
|
31
|
+
* The rules below enforce only exact syntax: transport bypass, wiring built inline instead of
|
|
32
|
+
* through the shared world, absence of any persisted-state read, direct provider imports, sleeps and
|
|
33
|
+
* branches. They do not pretend to judge business meaning.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** Files this law governs. A flow is a named lane, not every file that happens to touch a database. */
|
|
37
|
+
const isE2eSpec = (filename) => /\.e2e-spec\.ts$/.test(String(filename || "").replace(/\\/g, "/"))
|
|
38
|
+
|
|
39
|
+
/** Names a repository sleeps by. Awaiting any of them is waiting for a duration instead of a state. */
|
|
40
|
+
const SLEEPERS = new Set(["sleep", "delay", "wait", "pause", "setTimeout"])
|
|
41
|
+
|
|
42
|
+
/** E2E enters through production transport and never invokes an internal actor directly. */
|
|
43
|
+
export const e2eUsesProductionTransport = {
|
|
44
|
+
meta: {
|
|
45
|
+
type: "problem",
|
|
46
|
+
docs: { description: "Operational E2E keeps the production transport boundary in the proof." },
|
|
47
|
+
schema: [],
|
|
48
|
+
messages: {
|
|
49
|
+
busImport: "`{{name}}` is an internal dispatcher. Enter through GraphQL, HTTP, socket, broker or scheduler.",
|
|
50
|
+
direct: "Direct `.{{method}}()` skips production routing and delivery semantics.",
|
|
51
|
+
actor: "Calling `{{name}}` directly bypasses the broker/CQRS boundary that owns it.",
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
create(context) {
|
|
55
|
+
if (!isE2eSpec(context.filename || context.getFilename())) return {}
|
|
56
|
+
const buses = new Set(["CommandBus", "QueryBus", "EventBus"])
|
|
57
|
+
return {
|
|
58
|
+
ImportDeclaration(node) {
|
|
59
|
+
if (node.source.value !== "@nestjs/cqrs") return
|
|
60
|
+
for (const specifier of node.specifiers) {
|
|
61
|
+
if (specifier.type !== "ImportSpecifier") continue
|
|
62
|
+
const imported = specifier.imported.name || specifier.imported.value
|
|
63
|
+
if (buses.has(imported)) context.report({ node: specifier, messageId: "busImport", data: { name: imported } })
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
CallExpression(node) {
|
|
67
|
+
const callee = node.callee
|
|
68
|
+
if (callee.type !== "MemberExpression" || callee.computed) return
|
|
69
|
+
const method = callee.property.name
|
|
70
|
+
if (method === "execute" || method === "process") {
|
|
71
|
+
context.report({ node: callee.property, messageId: "direct", data: { method } })
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
let receiver = callee.object
|
|
75
|
+
while (receiver && ["TSAsExpression", "TSTypeAssertion", "ChainExpression"].includes(receiver.type)) receiver = receiver.expression
|
|
76
|
+
if (receiver && receiver.type === "Identifier" && /(?:Worker|Handler)$/.test(receiver.name)) {
|
|
77
|
+
context.report({ node: callee.property, messageId: "actor", data: { name: receiver.name } })
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// -- E2E-3 ----------------------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/** A flow polls until the state settles; it never waits for a duration. */
|
|
87
|
+
export const noSleepInFlow = {
|
|
88
|
+
meta: {
|
|
89
|
+
type: "problem",
|
|
90
|
+
docs: { description: "A flow polls for the state it needs, with a deadline. It never sleeps." },
|
|
91
|
+
schema: [],
|
|
92
|
+
messages: {
|
|
93
|
+
sleep:
|
|
94
|
+
"`{{name}}` waits for a DURATION, and a duration is a guess about somebody else's machine. It is too long on the machine that passes - every run pays it - and too short on the one that matters, where it fails as a flake nobody can reproduce. Poll for the state you are actually waiting for, with a deadline, so the test says what it wanted rather than how long it was willing to sit there.",
|
|
95
|
+
timer:
|
|
96
|
+
"A promise around `setTimeout` is a sleep with the name taken off. The objection is the same: nothing here says which state the flow is waiting for, so the failure message when it expires names a timeout instead of the thing that never happened. Poll the state, with a deadline.",
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
create(context) {
|
|
100
|
+
if (!isE2eSpec(context.filename || context.getFilename())) return {}
|
|
101
|
+
return {
|
|
102
|
+
CallExpression(node) {
|
|
103
|
+
const callee = node.callee
|
|
104
|
+
if (!callee || callee.type !== "Identifier" || !SLEEPERS.has(callee.name)) return
|
|
105
|
+
// A `setTimeout` wrapped in `new Promise` is reported once, as `timer`, by the branch below.
|
|
106
|
+
// Reporting it here as well would put two findings on one sleep and teach nobody anything.
|
|
107
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
108
|
+
if (current.type === "NewExpression" && current.callee && current.callee.name === "Promise") return
|
|
109
|
+
}
|
|
110
|
+
context.report({ node, messageId: "sleep", data: { name: callee.name } })
|
|
111
|
+
},
|
|
112
|
+
NewExpression(node) {
|
|
113
|
+
if (!node.callee || node.callee.name !== "Promise") return
|
|
114
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
115
|
+
if (/setTimeout/.test(source.getText(node))) context.report({ node, messageId: "timer" })
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// -- E2E-7 ----------------------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/** A step asserts one outcome. A branch means the test is prepared for either, which is no assertion. */
|
|
124
|
+
export const noBranchInFlowStep = {
|
|
125
|
+
meta: {
|
|
126
|
+
type: "problem",
|
|
127
|
+
docs: { description: "A flow step takes no branch: it asserts the one outcome the business promises." },
|
|
128
|
+
schema: [],
|
|
129
|
+
messages: {
|
|
130
|
+
branch:
|
|
131
|
+
"A branch inside a step means this test passes down either path, so a green run stops being evidence that the business worked - it only proves the code reached the end. The flow knows which outcome it is asserting: state it. If two outcomes are both legitimate, they are two steps, or two flows.",
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
create(context) {
|
|
135
|
+
if (!isE2eSpec(context.filename || context.getFilename())) return {}
|
|
136
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
137
|
+
|
|
138
|
+
/** True when this node sits inside the callback of an `it(...)`. */
|
|
139
|
+
const insideStep = (node) => {
|
|
140
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
141
|
+
if (current.type !== "CallExpression") continue
|
|
142
|
+
const callee = current.callee
|
|
143
|
+
const name = callee && (callee.name || (callee.object && callee.object.name))
|
|
144
|
+
if (name === "it" || name === "test") return true
|
|
145
|
+
}
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const report = (node) => {
|
|
150
|
+
if (insideStep(node)) context.report({ node, messageId: "branch" })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
IfStatement: report,
|
|
155
|
+
ConditionalExpression: report,
|
|
156
|
+
SwitchStatement: report,
|
|
157
|
+
LogicalExpression(node) {
|
|
158
|
+
// `a && b` used as a STATEMENT is a hidden if; the same operator inside an assertion is not
|
|
159
|
+
if (node.parent && node.parent.type === "ExpressionStatement") report(node)
|
|
160
|
+
// touching the source keeps the helper honest if the shape ever changes
|
|
161
|
+
void source
|
|
162
|
+
},
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// -- E2E-8 (the per-file half: a spec builds no wiring of its own) --------------------------------
|
|
168
|
+
|
|
169
|
+
/** A flow boots through the shared world; it does not build its own copy of the app. */
|
|
170
|
+
export const noWiringInFlowSpec = {
|
|
171
|
+
meta: {
|
|
172
|
+
type: "problem",
|
|
173
|
+
docs: {
|
|
174
|
+
description:
|
|
175
|
+
"An e2e spec boots through the shared world helper and builds no testing module of its own (E2E-8).",
|
|
176
|
+
},
|
|
177
|
+
schema: [],
|
|
178
|
+
messages: {
|
|
179
|
+
wiring:
|
|
180
|
+
"`Test.createTestingModule(...)` builds this flow's own copy of the app instead of booting through the shared world helper. E2E-8: the world is stood up in one place, and a spec file carries no wiring of its own - when the wiring changes, every flow that inlined its own module has to change with it, and two flows standing the world up slightly differently is how nobody learns where they differ. Boot through the shared helper and override only the one token this flow needs.",
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
create(context) {
|
|
184
|
+
if (!isE2eSpec(context.filename || context.getFilename())) return {}
|
|
185
|
+
return {
|
|
186
|
+
CallExpression(node) {
|
|
187
|
+
const callee = node.callee
|
|
188
|
+
if (!callee || callee.type !== "MemberExpression" || callee.computed) return
|
|
189
|
+
if (callee.object.type !== "Identifier" || callee.object.name !== "Test") return
|
|
190
|
+
if (callee.property.name !== "createTestingModule") return
|
|
191
|
+
context.report({ node: callee, messageId: "wiring" })
|
|
192
|
+
},
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The rules this law contributes to the plugin. */
|
|
198
|
+
export const rules = {
|
|
199
|
+
"e2e-uses-production-transport": e2eUsesProductionTransport,
|
|
200
|
+
"no-sleep-in-flow": noSleepInFlow,
|
|
201
|
+
"no-branch-in-flow-step": noBranchInFlowStep,
|
|
202
|
+
"no-wiring-in-flow-spec": noWiringInFlowSpec,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
207
|
+
*
|
|
208
|
+
* The first three are exact and fire on a syntactic shape rather than on a judgement, so none of
|
|
209
|
+
* them carries the false-positive risk that would justify adopting it at `warn`.
|
|
210
|
+
*
|
|
211
|
+
* `no-wiring-in-flow-spec` is exact in the SAME sense - `Test.createTestingModule(...)` is a fixed
|
|
212
|
+
* shape, not a guess - but it was measured at 60 of 77 e2e files in the reference repository
|
|
213
|
+
* building their own testing module rather than calling `bootFlowWorld`. That is real, pre-existing
|
|
214
|
+
* debt this task does not fix, so the rule lands at `warn` with the count rather than `error`,
|
|
215
|
+
* exactly like a rule that has not yet been burned down.
|
|
216
|
+
*/
|
|
217
|
+
export const recommended = {
|
|
218
|
+
"starci-be/e2e-uses-production-transport": "error",
|
|
219
|
+
"starci-be/no-sleep-in-flow": "error",
|
|
220
|
+
"starci-be/no-branch-in-flow-step": "error",
|
|
221
|
+
"starci-be/no-wiring-in-flow-spec": "warn", // no=60 of 77 - pre-existing, not burned down here
|
|
222
|
+
}
|