@starci/eslint-canon-be 1.8.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +95 -5
  2. package/README.md +41 -52
  3. package/async-discipline.mjs +2 -2
  4. package/authorization.mjs +38 -88
  5. package/builders.mjs +305 -0
  6. package/comments.mjs +19 -34
  7. package/config-owner.mjs +127 -125
  8. package/connections.mjs +254 -0
  9. package/cqrs.mjs +426 -135
  10. package/data-access.mjs +320 -391
  11. package/default-deny.mjs +14 -10
  12. package/e2e-flow.mjs +208 -180
  13. package/error-handling.mjs +254 -128
  14. package/idempotency.mjs +98 -67
  15. package/index.mjs +32 -9
  16. package/infra-owner.mjs +96 -0
  17. package/injection.mjs +602 -0
  18. package/input-bounds.mjs +166 -66
  19. package/lib/config.mjs +70 -33
  20. package/lib/db-writes.mjs +58 -0
  21. package/lib/declared.mjs +154 -0
  22. package/lib/hfs.mjs +61 -0
  23. package/lib/import-source.mjs +104 -0
  24. package/lib/persistence.mjs +160 -0
  25. package/lib/ports.mjs +138 -0
  26. package/lib/transport-slots.mjs +14 -0
  27. package/lib/types.mjs +133 -0
  28. package/lib/unit-spec.mjs +33 -0
  29. package/log-safety.mjs +94 -26
  30. package/module-layering.mjs +180 -297
  31. package/module-shape.mjs +296 -59
  32. package/naming.mjs +44 -12
  33. package/observability.mjs +57 -91
  34. package/operations.mjs +201 -0
  35. package/package.json +10 -7
  36. package/query-safety.mjs +189 -223
  37. package/resilience.mjs +126 -31
  38. package/runtime/engine/plain-object.mjs +5 -0
  39. package/runtime/engine/runtime-root.mjs +32 -0
  40. package/runtime/engine/yaml.mjs +161 -0
  41. package/runtime/knowledge/hfs/slots.yaml +1111 -0
  42. package/runtime/scripts/lib/git.mjs +53 -0
  43. package/runtime/scripts/lib/glob.mjs +23 -0
  44. package/runtime/scripts/lib/hfs-allows.mjs +57 -0
  45. package/runtime/scripts/lib/hfs-slots.mjs +729 -0
  46. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  47. package/runtime/scripts/lib/language.mjs +107 -0
  48. package/runtime/scripts/lib/path-key.mjs +17 -0
  49. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  50. package/schema-authority.mjs +273 -91
  51. package/size-budget.mjs +19 -61
  52. package/spec-no-skip.mjs +84 -0
  53. package/spec-quality.mjs +76 -35
  54. package/suppression.mjs +34 -16
  55. package/temporal.mjs +101 -36
  56. package/test-world.mjs +204 -0
  57. package/testing.mjs +99 -251
  58. package/transport.mjs +433 -142
  59. package/type-safety.mjs +50 -278
  60. package/unit-spec.mjs +452 -0
  61. package/user-copy.mjs +56 -37
  62. package/lib/slots.mjs +0 -83
package/default-deny.mjs CHANGED
@@ -6,13 +6,16 @@
6
6
  * - `no-untyped-body` refuses a `@Body()` (or `@Args()`) parameter with no type, `unknown`, `any`, `object`,
7
7
  * `{}` or `Record<string, unknown>`; it refuses `GraphQLJSON` as a tunnel around the schema; and it refuses
8
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.
9
+ * - `public-needs-reason` refuses `@Public()` without `{ reason: PublicReason.X }`, where `PublicReason` is the enum
10
+ * `domain/identity` exports (judged by the enum's declaring owner, type-aware). An open door states why it is open
11
+ * from a closed list, so the open doors can be read and reviewed; a string or another enum is refused.
11
12
  *
12
13
  * The `APP_GUARD` registration and the app-level guard live in the composition check, not here: a rule reading
13
14
  * one file cannot see which module the app registered.
14
15
  */
15
- import { decoratorName, keyName, staticText } from "./lib/ast.mjs"
16
+ import { decoratorName, keyName } from "./lib/ast.mjs"
17
+ import { isOwnedEnumMember } from "./lib/declared.mjs"
18
+ import { hfsOf } from "./lib/hfs.mjs"
16
19
  import { isDeclarationFile, normalizePath } from "./lib/path.mjs"
17
20
 
18
21
  const BODY_DECORATORS = new Set(["Body", "Args"])
@@ -45,7 +48,8 @@ export const noUntypedBody = {
45
48
  create(context) {
46
49
  const filename = normalizePath(context.filename || context.getFilename())
47
50
  if (isDeclarationFile(filename)) return {}
48
- const inTransport = filename.includes("/transport/")
51
+ const slot = hfsOf(context).slotOf(filename) ?? ""
52
+ const inTransport = slot.startsWith("be.transport.") || slot.startsWith("be.feature.transport.")
49
53
  return {
50
54
  Identifier(node) {
51
55
  if (node.name === "GraphQLJSON" && !/^Import.*Specifier$/.test(node.parent?.type ?? "")) {
@@ -74,14 +78,14 @@ export const noUntypedBody = {
74
78
  },
75
79
  }
76
80
 
77
- /** An open door states why it is open. */
81
+ /** An open door states why it is open, from the closed `PublicReason` list of `domain/identity`. */
78
82
  export const publicNeedsReason = {
79
83
  meta: {
80
84
  type: "problem",
81
- docs: { description: "`@Public()` carries `{ reason: \"<why>\" }`." },
85
+ docs: { description: "`@Public()` carries `{ reason: PublicReason.X }` with `PublicReason` from `domain/identity`." },
82
86
  schema: [],
83
87
  messages: {
84
- reason: "`@Public()` has no reason. Write `@Public({ reason: \"<why this operation is open>\" })` so the open doors can be reviewed.",
88
+ reason: "`@Public()` needs `{ reason: PublicReason.X }` with a member of the `PublicReason` enum exported by `domain/identity`; a string, another enum or a missing reason is refused. A new reason is a change to the canon, not to this file.",
85
89
  },
86
90
  },
87
91
  create(context) {
@@ -94,10 +98,10 @@ export const publicNeedsReason = {
94
98
  const argument = expression.type === "CallExpression" ? expression.arguments[0] : undefined
95
99
  let reason = null
96
100
  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
101
+ const property = argument.properties.find((item) => item.type === "Property" && !item.computed && keyName(item.key) === "reason")
102
+ reason = property ? property.value : null
99
103
  }
100
- if (reason === null || reason.trim() === "") context.report({ node, messageId: "reason" })
104
+ if (reason === null || !isOwnedEnumMember(context, reason, "PublicReason", "domain", "identity")) context.report({ node, messageId: "reason" })
101
105
  },
102
106
  }
103
107
  },
package/e2e-flow.mjs CHANGED
@@ -1,222 +1,250 @@
1
1
  /**
2
- * The rules that hold `e2e-flow.md`.
2
+ * The rules that hold `e2e-flow.md` (catalog R48 `BE_SPEC_QUALITY`, the e2e half).
3
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.
4
+ * A rule earns its place by being exact: it fires on a syntactic or typed shape, never on a judgement, or it becomes
5
+ * something authors learn to work around. What these four enforce is transport bypass (a bus or an actor called
6
+ * directly), a sleep instead of a poll, a branch inside a step, and a testing module built inside a spec instead of
7
+ * booted by the test world (`useTestWorld({ apps })`, `src/tests/world/`). They do not pretend to judge business meaning: whether a file is one flow, whether
8
+ * its steps are named, and what the absence of an effect is proved by are read by a person.
8
9
  *
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.
10
+ * Receivers are identified by their TypeScript type and by the file their type is declared in, never by a variable
11
+ * name (`lib/types.mjs`); a spec is recognised by its file name (`*.e2e-spec.ts`), which is part of the slot
12
+ * vocabulary.
34
13
  */
14
+ import { basename } from "node:path"
15
+ import ts from "typescript"
16
+ import { hfsOf } from "./lib/hfs.mjs"
17
+ import { isPackageType, typeOrigins, typed } from "./lib/types.mjs"
35
18
 
36
19
  /** 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, "/"))
20
+ const isE2eSpec = (filename) => /\.e2e-spec\.ts$/.test(basename(String(filename || "").replace(/\\/g, "/")))
21
+
22
+ /** The in-process dispatchers of `@nestjs/cqrs`: a flow enters through transport, not through them. */
23
+ const BUS_TYPES = ["CommandBus", "QueryBus", "EventBus"]
38
24
 
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"])
25
+ /** The role suffixes of the actors a flow never invokes itself: a handler, a consumer, a job. */
26
+ const ACTOR_FILE = /\.(?:handler|consumer|job)\.ts$/
27
+
28
+ /**
29
+ * What a name in scope is: `{ global: true }` when nothing in the file declares it, `{ source, imported }` when it is an
30
+ * import binding, else null (a local declaration).
31
+ */
32
+ const bindingOf = (context, node, name) => {
33
+ let scope = (context.sourceCode || context.getSourceCode()).getScope(node)
34
+ while (scope) {
35
+ const variable = scope.set.get(name)
36
+ if (variable) {
37
+ if (variable.defs.length === 0) return { global: true }
38
+ const definition = variable.defs[0]
39
+ if (definition.type !== "ImportBinding" || definition.node.type !== "ImportSpecifier") return null
40
+ return { source: definition.parent.source.value, imported: definition.node.imported.name ?? definition.node.imported.value }
41
+ }
42
+ scope = scope.upper
43
+ }
44
+ return { global: true }
45
+ }
41
46
 
42
47
  /** E2E enters through production transport and never invokes an internal actor directly. */
43
48
  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.",
49
+ meta: {
50
+ type: "problem",
51
+ docs: { description: "Operational E2E keeps the production transport boundary in the proof." },
52
+ schema: [],
53
+ messages: {
54
+ busImport: "`{{name}}` is an internal dispatcher. Enter through GraphQL, HTTP, socket, broker or scheduler.",
55
+ direct: "Calling `.{{method}}()` on a `{{name}}` skips production routing and delivery semantics. Enter through the transport.",
56
+ actor: "Calling `.{{method}}()` on `{{name}}`, declared in `{{file}}`, bypasses the transport and the broker that own it. Send the request or the message a client sends.",
57
+ },
52
58
  },
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
59
+ create(context) {
60
+ if (!isE2eSpec(context.filename || context.getFilename())) return {}
61
+ return {
62
+ ImportDeclaration(node) {
63
+ if (node.source.value !== "@nestjs/cqrs") return
64
+ for (const specifier of node.specifiers) {
65
+ if (specifier.type !== "ImportSpecifier") continue
66
+ const imported = specifier.imported.name || specifier.imported.value
67
+ if (BUS_TYPES.includes(imported)) context.report({ node: specifier, messageId: "busImport", data: { name: imported } })
68
+ }
69
+ },
70
+ CallExpression(node) {
71
+ const callee = node.callee
72
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return
73
+ const method = callee.property.name
74
+ const bus = BUS_TYPES.find((name) => isPackageType(context, callee.object, name, "@nestjs/cqrs"))
75
+ if (bus) {
76
+ context.report({ node: callee.property, messageId: "direct", data: { method, name: bus } })
77
+ return
78
+ }
79
+ const actor = typeOrigins(context, callee.object).find((origin) => ACTOR_FILE.test(origin.file))
80
+ if (actor) context.report({ node: callee.property, messageId: "actor", data: { method, name: actor.name, file: basename(actor.file) } })
81
+ },
73
82
  }
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
- },
83
+ },
82
84
  }
83
85
 
84
86
  // -- E2E-3 ----------------------------------------------------------------------------------------
85
87
 
88
+ /** The modules whose `setTimeout`/`setInterval` are timers. */
89
+ const TIMER_MODULES = ["timers", "node:timers", "timers/promises", "node:timers/promises"]
90
+
91
+ /** Whether a call awaits a fixed duration: a global or imported timer, or a `Promise<void>` call given one number. */
92
+ const waitsForDuration = (context, node) => {
93
+ const callee = node.callee
94
+ const binding = callee.type === "Identifier" ? bindingOf(context, node, callee.name) : null
95
+ if (binding?.global && (callee.name === "setTimeout" || callee.name === "setInterval")) return true
96
+ if (binding?.source && TIMER_MODULES.includes(binding.source) && (binding.imported === "setTimeout" || binding.imported === "setInterval")) return true
97
+ if (node.arguments.length !== 1 || node.arguments[0].type === "SpreadElement") return false
98
+ const { checker, toTs } = typed(context)
99
+ const argument = checker.getTypeAtLocation(toTs(node.arguments[0]))
100
+ if (!(argument.flags & (ts.TypeFlags.Number | ts.TypeFlags.NumberLiteral))) return false
101
+ return checker.typeToString(checker.getTypeAtLocation(toTs(node))) === "Promise<void>"
102
+ }
103
+
86
104
  /** A flow polls until the state settles; it never waits for a duration. */
87
105
  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.",
106
+ meta: {
107
+ type: "problem",
108
+ docs: { description: "A flow polls for the state it needs, with a deadline. It never sleeps." },
109
+ schema: [],
110
+ messages: {
111
+ sleep:
112
+ "This call 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 `waitFor`, which carries a deadline, so the test says what it wanted rather than how long it was willing to sit there.",
113
+ timer:
114
+ "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 `waitFor`.",
115
+ },
97
116
  },
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
117
+ create(context) {
118
+ if (!isE2eSpec(context.filename || context.getFilename())) return {}
119
+ const insidePromise = (node) => {
120
+ for (let current = node.parent; current; current = current.parent) {
121
+ if (current.type === "NewExpression" && current.callee.type === "Identifier" && current.callee.name === "Promise") return current
122
+ }
123
+ return null
109
124
  }
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
- },
125
+ const reportedPromises = new Set()
126
+ return {
127
+ CallExpression(node) {
128
+ if (!waitsForDuration(context, node)) return
129
+ // A timer wrapped in `new Promise` is one finding, on the promise, not two on one sleep.
130
+ const wrapper = insidePromise(node)
131
+ if (wrapper) {
132
+ if (!reportedPromises.has(wrapper)) {
133
+ reportedPromises.add(wrapper)
134
+ context.report({ node: wrapper, messageId: "timer" })
135
+ }
136
+ return
137
+ }
138
+ context.report({ node, messageId: "sleep" })
139
+ },
140
+ }
141
+ },
119
142
  }
120
143
 
121
144
  // -- E2E-7 ----------------------------------------------------------------------------------------
122
145
 
123
- /** A step asserts one outcome. A branch means the test is prepared for either, which is no assertion. */
146
+ /**
147
+ * A step asserts one outcome. A branch means the test is prepared for either, which is no assertion.
148
+ *
149
+ * The probe handed to `world.waitFor(label, probe)` is not a step: it is a polling predicate ("answer the row once it is
150
+ * there, else nothing"), and a test whose probe never becomes ready FAILS at the deadline, so no path passes by omission.
151
+ * A probe is recognised by the type of the receiver (`waitFor` on a value whose type is declared in the test world,
152
+ * slot `be.tests.world`), never by the name of a variable; a `waitFor` of any other type, and every branch outside the
153
+ * probe, is still a branch in a step.
154
+ */
124
155
  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.",
156
+ meta: {
157
+ type: "problem",
158
+ docs: { description: "A flow step takes no branch: it asserts the one outcome the business promises." },
159
+ schema: [],
160
+ messages: {
161
+ branch:
162
+ "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.",
163
+ },
132
164
  },
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
- }
165
+ create(context) {
166
+ if (!isE2eSpec(context.filename || context.getFilename())) return {}
167
+ const hfs = hfsOf(context)
148
168
 
149
- const report = (node) => {
150
- if (insideStep(node)) context.report({ node, messageId: "branch" })
151
- }
169
+ /** True when the call is `world.waitFor(...)` on the test world's own handle. */
170
+ const isWorldPoll = (call) =>
171
+ call.callee.type === "MemberExpression" &&
172
+ !call.callee.computed &&
173
+ call.callee.property.type === "Identifier" &&
174
+ call.callee.property.name === "waitFor" &&
175
+ typeOrigins(context, call.callee.object).some((origin) => hfs.slotOf(origin.file) === "be.tests.world")
152
176
 
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
- },
177
+ /** True when this node sits inside the callback of an `it(...)` or `test(...)`, and not inside a `waitFor` probe. */
178
+ const insideStep = (node) => {
179
+ let child = node
180
+ for (let current = node.parent; current; current = current.parent) {
181
+ if (current.type === "CallExpression") {
182
+ const isFunction = child.type === "ArrowFunctionExpression" || child.type === "FunctionExpression"
183
+ if (isFunction && current.arguments.includes(child) && isWorldPoll(current)) return false
184
+ const callee = current.callee
185
+ const name = callee && (callee.name || (callee.object && callee.object.name))
186
+ if (name === "it" || name === "test") return true
187
+ }
188
+ child = current
189
+ }
190
+ return false
191
+ }
192
+
193
+ const report = (node) => {
194
+ if (insideStep(node)) context.report({ node, messageId: "branch" })
195
+ }
196
+
197
+ return {
198
+ IfStatement: report,
199
+ ConditionalExpression: report,
200
+ SwitchStatement: report,
201
+ LogicalExpression(node) {
202
+ // `a && b` used as a STATEMENT is a hidden if; the same operator inside an assertion is not
203
+ if (node.parent && node.parent.type === "ExpressionStatement") report(node)
204
+ },
205
+ }
206
+ },
165
207
  }
166
208
 
167
209
  // -- E2E-8 (the per-file half: a spec builds no wiring of its own) --------------------------------
168
210
 
169
- /** A flow boots through the shared world; it does not build its own copy of the app. */
211
+ /** A flow boots through the shared setup; it does not build its own copy of the app. */
170
212
  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).",
213
+ meta: {
214
+ type: "problem",
215
+ docs: { description: "An e2e spec boots through the shared setup and builds no testing module of its own (E2E-8)." },
216
+ schema: [],
217
+ messages: {
218
+ wiring:
219
+ "`Test.createTestingModule(...)` builds this flow's own copy of the app. The world is stood up in one place, `src/tests/world/` through `useTestWorld({ apps })`, which boots the real app; 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 world; nothing is overridden, an external service is the world's network fake (`world.fake.<provider>`).",
220
+ },
176
221
  },
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.",
222
+ create(context) {
223
+ if (!isE2eSpec(context.filename || context.getFilename())) return {}
224
+ return {
225
+ CallExpression(node) {
226
+ const callee = node.callee
227
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.name !== "createTestingModule") return
228
+ if (callee.object.type !== "Identifier") return
229
+ const binding = bindingOf(context, node, callee.object.name)
230
+ if (binding?.source === "@nestjs/testing" && binding.imported === "Test") context.report({ node: callee, messageId: "wiring" })
231
+ },
232
+ }
181
233
  },
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
234
  }
196
235
 
197
- /** The rules this law contributes to the plugin. */
236
+
198
237
  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,
238
+ "e2e-uses-production-transport": e2eUsesProductionTransport,
239
+ "no-sleep-in-flow": noSleepInFlow,
240
+ "no-branch-in-flow-step": noBranchInFlowStep,
241
+ "no-wiring-in-flow-spec": noWiringInFlowSpec,
203
242
  }
204
243
 
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
- */
244
+ /** Every rule of this law ships at `error`. */
217
245
  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
246
+ "starci-be/e2e-uses-production-transport": "error",
247
+ "starci-be/no-sleep-in-flow": "error",
248
+ "starci-be/no-branch-in-flow-step": "error",
249
+ "starci-be/no-wiring-in-flow-spec": "error",
222
250
  }