@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.
- package/CHANGELOG.md +95 -5
- package/README.md +41 -52
- package/async-discipline.mjs +2 -2
- package/authorization.mjs +38 -88
- package/builders.mjs +305 -0
- package/comments.mjs +19 -34
- package/config-owner.mjs +127 -125
- package/connections.mjs +254 -0
- package/cqrs.mjs +426 -135
- package/data-access.mjs +320 -391
- package/default-deny.mjs +14 -10
- package/e2e-flow.mjs +208 -180
- package/error-handling.mjs +254 -128
- package/idempotency.mjs +98 -67
- package/index.mjs +32 -9
- package/infra-owner.mjs +96 -0
- package/injection.mjs +602 -0
- package/input-bounds.mjs +166 -66
- package/lib/config.mjs +70 -33
- package/lib/db-writes.mjs +58 -0
- package/lib/declared.mjs +154 -0
- package/lib/hfs.mjs +61 -0
- package/lib/import-source.mjs +104 -0
- package/lib/persistence.mjs +160 -0
- package/lib/ports.mjs +138 -0
- package/lib/transport-slots.mjs +14 -0
- package/lib/types.mjs +133 -0
- package/lib/unit-spec.mjs +33 -0
- package/log-safety.mjs +94 -26
- package/module-layering.mjs +180 -297
- package/module-shape.mjs +296 -59
- package/naming.mjs +44 -12
- package/observability.mjs +57 -91
- package/operations.mjs +201 -0
- package/package.json +10 -7
- package/query-safety.mjs +189 -223
- package/resilience.mjs +126 -31
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/engine/runtime-root.mjs +32 -0
- package/runtime/engine/yaml.mjs +161 -0
- package/runtime/knowledge/hfs/slots.yaml +1111 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/glob.mjs +23 -0
- package/runtime/scripts/lib/hfs-allows.mjs +57 -0
- package/runtime/scripts/lib/hfs-slots.mjs +729 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/path-key.mjs +17 -0
- package/runtime/scripts/lib/recorded-lines.mjs +27 -0
- package/schema-authority.mjs +273 -91
- package/size-budget.mjs +19 -61
- package/spec-no-skip.mjs +84 -0
- package/spec-quality.mjs +76 -35
- package/suppression.mjs +34 -16
- package/temporal.mjs +101 -36
- package/test-world.mjs +204 -0
- package/testing.mjs +99 -251
- package/transport.mjs +433 -142
- package/type-safety.mjs +50 -278
- package/unit-spec.mjs +452 -0
- package/user-copy.mjs +56 -37
- 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:
|
|
10
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
85
|
+
docs: { description: "`@Public()` carries `{ reason: PublicReason.X }` with `PublicReason` from `domain/identity`." },
|
|
82
86
|
schema: [],
|
|
83
87
|
messages: {
|
|
84
|
-
reason: "`@Public()`
|
|
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 ?
|
|
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 ||
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
/**
|
|
40
|
-
const
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
211
|
+
/** A flow boots through the shared setup; it does not build its own copy of the app. */
|
|
170
212
|
export const noWiringInFlowSpec = {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
236
|
+
|
|
198
237
|
export const rules = {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
}
|