@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.
@@ -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
+ }