@starci/eslint-canon-be 1.0.0 → 1.1.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/README.md +69 -0
- package/cdc.mjs +44 -44
- package/e2e-flow.mjs +172 -213
- package/event-delivery.mjs +40 -40
- package/index.mjs +19 -2
- package/package.json +1 -1
- package/testing.mjs +0 -46
- package/transport.mjs +103 -103
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# @starci/eslint-canon-be
|
|
2
|
+
|
|
3
|
+
**37 ESLint rules, from 15 laws, that hold a NestJS-shaped back end to one way of being written.**
|
|
4
|
+
|
|
5
|
+
Not a style pack. These rules enforce *architecture*: which layer may import which, whether a
|
|
6
|
+
failure carries its own identity, where a query is allowed to be built, what an end-to-end test is
|
|
7
|
+
permitted to assume. Prettier decides how code looks; this decides what it is allowed to be.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm i -D @starci/eslint-canon-be
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Use it
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import starciBe, { recommended } from "@starci/eslint-canon-be"
|
|
17
|
+
|
|
18
|
+
export default [
|
|
19
|
+
// …your own ignores, language options and other plugins…
|
|
20
|
+
{
|
|
21
|
+
files: ["src/**/*.ts"],
|
|
22
|
+
plugins: { "starci-be": starciBe },
|
|
23
|
+
rules: recommended,
|
|
24
|
+
},
|
|
25
|
+
]
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Which globs the law applies to is your repository's fact. What the law says is not — so there is no
|
|
29
|
+
option to switch a rule off or lower it to a warning.
|
|
30
|
+
|
|
31
|
+
Also exported: `rules`, `ruleOwners`, `lawOwners`.
|
|
32
|
+
|
|
33
|
+
## What it actually catches
|
|
34
|
+
|
|
35
|
+
A sample, not the list:
|
|
36
|
+
|
|
37
|
+
| Area | What the rules hold |
|
|
38
|
+
|---|---|
|
|
39
|
+
| **Exceptions** | Every failure throws a domain exception carrying its own identity and metadata — never a bare `Error`, never a framework built-in, never an exception declared outside the exceptions folder |
|
|
40
|
+
| **CQRS** | A handler does not assemble an aggregate inline; reads and writes do not share one path |
|
|
41
|
+
| **Module layering** | An import that climbs a layer it was not given is a build failure, not a review comment |
|
|
42
|
+
| **Data access** | Queries stay where the layer says they may be built |
|
|
43
|
+
| **Transport** | The wire shape is declared, not inferred from whatever a handler happened to return |
|
|
44
|
+
| **Observability** | A failure is logged as a typed exception, so a log line can be traced to the law that names it |
|
|
45
|
+
| **End-to-end flows** | One file, one flow, named steps, and **never sleep** — poll until the state settles, with a deadline |
|
|
46
|
+
| **CDC · event delivery** | Projections and events follow the declared delivery contract |
|
|
47
|
+
| **Comments · naming · type safety** | Comments say why; no double cast through `unknown` |
|
|
48
|
+
|
|
49
|
+
Every rule names the law that declares it — `ruleOwners` maps rule name to law, so a failing build
|
|
50
|
+
line leads straight to the document that explains why.
|
|
51
|
+
|
|
52
|
+
## Companion
|
|
53
|
+
|
|
54
|
+
- **[@starci/eslint-canon-fe](https://www.npmjs.com/package/@starci/eslint-canon-fe)** — the
|
|
55
|
+
front-end half: 58 rules from 16 laws covering component tiers, structure contracts, the
|
|
56
|
+
fetch/draw split, vendor boundaries and the design-token scale.
|
|
57
|
+
|
|
58
|
+
## Where the laws live
|
|
59
|
+
|
|
60
|
+
Each rule is the enforceable half of a written law. The prose — why the rule exists, what it
|
|
61
|
+
refuses, which cases sit just outside it — is published openly at
|
|
62
|
+
[starci183/starci-claude-skills](https://github.com/starci183/starci-claude-skills).
|
|
63
|
+
|
|
64
|
+
A rule that cannot be pointed at in real code is a proposal, not a law. Everything here is
|
|
65
|
+
pointed at.
|
|
66
|
+
|
|
67
|
+
## Requirements
|
|
68
|
+
|
|
69
|
+
ESLint 9+ (flat config), Node 20.9+.
|
package/cdc.mjs
CHANGED
|
@@ -1,44 +1,44 @@
|
|
|
1
|
-
const isProjectionListener = (filename) => /projection\.listener\.ts$/.test(
|
|
2
|
-
String(filename || "").replace(/\\/g, "/"),
|
|
3
|
-
) && !String(filename || "").replace(/\\/g, "/").endsWith("/abstract-projection.listener.ts")
|
|
4
|
-
|
|
5
|
-
const memberName = (member) => member.key && (member.key.name || member.key.value)
|
|
6
|
-
|
|
7
|
-
/** A projection listener declares mapping while AbstractProjectionListener owns Kafka lifecycle. */
|
|
8
|
-
export const projectionListenerContract = {
|
|
9
|
-
meta: {
|
|
10
|
-
type: "problem",
|
|
11
|
-
docs: { description: "Projection CDC listeners share one Kafka lifecycle contract." },
|
|
12
|
-
schema: [],
|
|
13
|
-
messages: {
|
|
14
|
-
base: "A projection CDC listener extends AbstractProjectionListener; the shared base owns subscription, envelope parsing and failure isolation.",
|
|
15
|
-
member: "A projection CDC listener must declare `{{name}}`.",
|
|
16
|
-
lifecycle: "A concrete projection listener must not override `onModuleInit`; Kafka lifecycle belongs to AbstractProjectionListener.",
|
|
17
|
-
},
|
|
18
|
-
},
|
|
19
|
-
create(context) {
|
|
20
|
-
if (!isProjectionListener(context.filename || context.getFilename())) return {}
|
|
21
|
-
return {
|
|
22
|
-
ClassDeclaration(node) {
|
|
23
|
-
if (!node.superClass || node.superClass.name !== "AbstractProjectionListener") {
|
|
24
|
-
context.report({ node: node.id || node, messageId: "base" })
|
|
25
|
-
}
|
|
26
|
-
const members = node.body.body || []
|
|
27
|
-
const names = new Set(members.map(memberName))
|
|
28
|
-
for (const name of ["groupId", "topics", "deriveTargets", "recomputeTarget"]) {
|
|
29
|
-
if (!names.has(name)) {
|
|
30
|
-
context.report({ node: node.id || node, messageId: "member", data: { name } })
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
const lifecycle = members.find((member) => memberName(member) === "onModuleInit")
|
|
34
|
-
if (lifecycle) context.report({ node: lifecycle.key, messageId: "lifecycle" })
|
|
35
|
-
},
|
|
36
|
-
}
|
|
37
|
-
},
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export const rules = { "projection-listener-contract": projectionListenerContract }
|
|
41
|
-
|
|
42
|
-
export const recommended = {
|
|
43
|
-
"starci-be/projection-listener-contract": "error",
|
|
44
|
-
}
|
|
1
|
+
const isProjectionListener = (filename) => /projection\.listener\.ts$/.test(
|
|
2
|
+
String(filename || "").replace(/\\/g, "/"),
|
|
3
|
+
) && !String(filename || "").replace(/\\/g, "/").endsWith("/abstract-projection.listener.ts")
|
|
4
|
+
|
|
5
|
+
const memberName = (member) => member.key && (member.key.name || member.key.value)
|
|
6
|
+
|
|
7
|
+
/** A projection listener declares mapping while AbstractProjectionListener owns Kafka lifecycle. */
|
|
8
|
+
export const projectionListenerContract = {
|
|
9
|
+
meta: {
|
|
10
|
+
type: "problem",
|
|
11
|
+
docs: { description: "Projection CDC listeners share one Kafka lifecycle contract." },
|
|
12
|
+
schema: [],
|
|
13
|
+
messages: {
|
|
14
|
+
base: "A projection CDC listener extends AbstractProjectionListener; the shared base owns subscription, envelope parsing and failure isolation.",
|
|
15
|
+
member: "A projection CDC listener must declare `{{name}}`.",
|
|
16
|
+
lifecycle: "A concrete projection listener must not override `onModuleInit`; Kafka lifecycle belongs to AbstractProjectionListener.",
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
create(context) {
|
|
20
|
+
if (!isProjectionListener(context.filename || context.getFilename())) return {}
|
|
21
|
+
return {
|
|
22
|
+
ClassDeclaration(node) {
|
|
23
|
+
if (!node.superClass || node.superClass.name !== "AbstractProjectionListener") {
|
|
24
|
+
context.report({ node: node.id || node, messageId: "base" })
|
|
25
|
+
}
|
|
26
|
+
const members = node.body.body || []
|
|
27
|
+
const names = new Set(members.map(memberName))
|
|
28
|
+
for (const name of ["groupId", "topics", "deriveTargets", "recomputeTarget"]) {
|
|
29
|
+
if (!names.has(name)) {
|
|
30
|
+
context.report({ node: node.id || node, messageId: "member", data: { name } })
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const lifecycle = members.find((member) => memberName(member) === "onModuleInit")
|
|
34
|
+
if (lifecycle) context.report({ node: lifecycle.key, messageId: "lifecycle" })
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const rules = { "projection-listener-contract": projectionListenerContract }
|
|
41
|
+
|
|
42
|
+
export const recommended = {
|
|
43
|
+
"starci-be/projection-listener-contract": "error",
|
|
44
|
+
}
|
package/e2e-flow.mjs
CHANGED
|
@@ -1,213 +1,172 @@
|
|
|
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.
|
|
18
|
-
* - E2E-8 (one place stands the world up) is a fact about a repository's fixtures, not about a
|
|
19
|
-
* file, so it belongs to a gate that can see the tree rather than to a rule that sees one file.
|
|
20
|
-
* - E2E-10 (a flow logs nothing) is already held: `no-console` and `no-framework-logger` in the
|
|
21
|
-
* observability law cover every call site, and a second rule saying the same thing in this lane
|
|
22
|
-
* would double every report.
|
|
23
|
-
*
|
|
24
|
-
* The rules below enforce only exact syntax: transport bypass, absence of any persisted-state read,
|
|
25
|
-
* direct provider imports, sleeps and branches. They do not pretend to judge business meaning.
|
|
26
|
-
*/
|
|
27
|
-
|
|
28
|
-
/** Files this law governs. A flow is a named lane, not every file that happens to touch a database. */
|
|
29
|
-
const isE2eSpec = (filename) => /\.e2e-spec\.ts$/.test(String(filename || "").replace(/\\/g, "/"))
|
|
30
|
-
|
|
31
|
-
/** Names a repository sleeps by. Awaiting any of them is waiting for a duration instead of a state. */
|
|
32
|
-
const SLEEPERS = new Set(["sleep", "delay", "wait", "pause", "setTimeout"])
|
|
33
|
-
|
|
34
|
-
/** E2E enters through production transport and never invokes an internal actor directly. */
|
|
35
|
-
export const e2eUsesProductionTransport = {
|
|
36
|
-
meta: {
|
|
37
|
-
type: "problem",
|
|
38
|
-
docs: { description: "Operational E2E keeps the production transport boundary in the proof." },
|
|
39
|
-
schema: [],
|
|
40
|
-
messages: {
|
|
41
|
-
busImport: "`{{name}}` is an internal dispatcher. Enter through GraphQL, HTTP, socket, broker or scheduler.",
|
|
42
|
-
direct: "Direct `.{{method}}()` skips production routing and delivery semantics.",
|
|
43
|
-
actor: "Calling `{{name}}` directly bypasses the broker/CQRS boundary that owns it.",
|
|
44
|
-
},
|
|
45
|
-
},
|
|
46
|
-
create(context) {
|
|
47
|
-
if (!isE2eSpec(context.filename || context.getFilename())) return {}
|
|
48
|
-
const buses = new Set(["CommandBus", "QueryBus", "EventBus"])
|
|
49
|
-
return {
|
|
50
|
-
ImportDeclaration(node) {
|
|
51
|
-
if (node.source.value !== "@nestjs/cqrs") return
|
|
52
|
-
for (const specifier of node.specifiers) {
|
|
53
|
-
if (specifier.type !== "ImportSpecifier") continue
|
|
54
|
-
const imported = specifier.imported.name || specifier.imported.value
|
|
55
|
-
if (buses.has(imported)) context.report({ node: specifier, messageId: "busImport", data: { name: imported } })
|
|
56
|
-
}
|
|
57
|
-
},
|
|
58
|
-
CallExpression(node) {
|
|
59
|
-
const callee = node.callee
|
|
60
|
-
if (callee.type !== "MemberExpression" || callee.computed) return
|
|
61
|
-
const method = callee.property.name
|
|
62
|
-
if (method === "execute" || method === "process") {
|
|
63
|
-
context.report({ node: callee.property, messageId: "direct", data: { method } })
|
|
64
|
-
return
|
|
65
|
-
}
|
|
66
|
-
let receiver = callee.object
|
|
67
|
-
while (receiver && ["TSAsExpression", "TSTypeAssertion", "ChainExpression"].includes(receiver.type)) receiver = receiver.expression
|
|
68
|
-
if (receiver && receiver.type === "Identifier" && /(?:Worker|Handler)$/.test(receiver.name)) {
|
|
69
|
-
context.report({ node: callee.property, messageId: "actor", data: { name: receiver.name } })
|
|
70
|
-
}
|
|
71
|
-
},
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const callee =
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const callee = current.callee
|
|
174
|
-
const name = callee && (callee.name || (callee.object && callee.object.name))
|
|
175
|
-
if (name === "it" || name === "test") return true
|
|
176
|
-
}
|
|
177
|
-
return false
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const report = (node) => {
|
|
181
|
-
if (insideStep(node)) context.report({ node, messageId: "branch" })
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return {
|
|
185
|
-
IfStatement: report,
|
|
186
|
-
ConditionalExpression: report,
|
|
187
|
-
SwitchStatement: report,
|
|
188
|
-
LogicalExpression(node) {
|
|
189
|
-
// `a && b` used as a STATEMENT is a hidden if; the same operator inside an assertion is not
|
|
190
|
-
if (node.parent && node.parent.type === "ExpressionStatement") report(node)
|
|
191
|
-
// touching the source keeps the helper honest if the shape ever changes
|
|
192
|
-
void source
|
|
193
|
-
},
|
|
194
|
-
}
|
|
195
|
-
},
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
/** The rules this law contributes to the plugin. */
|
|
199
|
-
export const rules = {
|
|
200
|
-
"e2e-uses-production-transport": e2eUsesProductionTransport,
|
|
201
|
-
"e2e-asserts-persisted-state": e2eAssertsPersistedState,
|
|
202
|
-
"no-model-call-in-e2e": noModelCallInE2e,
|
|
203
|
-
"no-sleep-in-flow": noSleepInFlow,
|
|
204
|
-
"no-branch-in-flow-step": noBranchInFlowStep,
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
/**
|
|
208
|
-
* The level this law asks for, as the plugin's own opinion.
|
|
209
|
-
*
|
|
210
|
-
* Both are exact and both fire on a syntactic shape rather than on a judgement, so neither carries
|
|
211
|
-
* the false-positive risk that would justify adopting it at `warn`.
|
|
212
|
-
*/
|
|
213
|
-
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-be/${name}`, "error"]))
|
|
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.
|
|
18
|
+
* - E2E-8 (one place stands the world up) is a fact about a repository's fixtures, not about a
|
|
19
|
+
* file, so it belongs to a gate that can see the tree rather than to a rule that sees one file.
|
|
20
|
+
* - E2E-10 (a flow logs nothing) is already held: `no-console` and `no-framework-logger` in the
|
|
21
|
+
* observability law cover every call site, and a second rule saying the same thing in this lane
|
|
22
|
+
* would double every report.
|
|
23
|
+
*
|
|
24
|
+
* The rules below enforce only exact syntax: transport bypass, absence of any persisted-state read,
|
|
25
|
+
* direct provider imports, sleeps and branches. They do not pretend to judge business meaning.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Files this law governs. A flow is a named lane, not every file that happens to touch a database. */
|
|
29
|
+
const isE2eSpec = (filename) => /\.e2e-spec\.ts$/.test(String(filename || "").replace(/\\/g, "/"))
|
|
30
|
+
|
|
31
|
+
/** Names a repository sleeps by. Awaiting any of them is waiting for a duration instead of a state. */
|
|
32
|
+
const SLEEPERS = new Set(["sleep", "delay", "wait", "pause", "setTimeout"])
|
|
33
|
+
|
|
34
|
+
/** E2E enters through production transport and never invokes an internal actor directly. */
|
|
35
|
+
export const e2eUsesProductionTransport = {
|
|
36
|
+
meta: {
|
|
37
|
+
type: "problem",
|
|
38
|
+
docs: { description: "Operational E2E keeps the production transport boundary in the proof." },
|
|
39
|
+
schema: [],
|
|
40
|
+
messages: {
|
|
41
|
+
busImport: "`{{name}}` is an internal dispatcher. Enter through GraphQL, HTTP, socket, broker or scheduler.",
|
|
42
|
+
direct: "Direct `.{{method}}()` skips production routing and delivery semantics.",
|
|
43
|
+
actor: "Calling `{{name}}` directly bypasses the broker/CQRS boundary that owns it.",
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
create(context) {
|
|
47
|
+
if (!isE2eSpec(context.filename || context.getFilename())) return {}
|
|
48
|
+
const buses = new Set(["CommandBus", "QueryBus", "EventBus"])
|
|
49
|
+
return {
|
|
50
|
+
ImportDeclaration(node) {
|
|
51
|
+
if (node.source.value !== "@nestjs/cqrs") return
|
|
52
|
+
for (const specifier of node.specifiers) {
|
|
53
|
+
if (specifier.type !== "ImportSpecifier") continue
|
|
54
|
+
const imported = specifier.imported.name || specifier.imported.value
|
|
55
|
+
if (buses.has(imported)) context.report({ node: specifier, messageId: "busImport", data: { name: imported } })
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
CallExpression(node) {
|
|
59
|
+
const callee = node.callee
|
|
60
|
+
if (callee.type !== "MemberExpression" || callee.computed) return
|
|
61
|
+
const method = callee.property.name
|
|
62
|
+
if (method === "execute" || method === "process") {
|
|
63
|
+
context.report({ node: callee.property, messageId: "direct", data: { method } })
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
let receiver = callee.object
|
|
67
|
+
while (receiver && ["TSAsExpression", "TSTypeAssertion", "ChainExpression"].includes(receiver.type)) receiver = receiver.expression
|
|
68
|
+
if (receiver && receiver.type === "Identifier" && /(?:Worker|Handler)$/.test(receiver.name)) {
|
|
69
|
+
context.report({ node: callee.property, messageId: "actor", data: { name: receiver.name } })
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// -- E2E-3 ----------------------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
/** A flow polls until the state settles; it never waits for a duration. */
|
|
79
|
+
export const noSleepInFlow = {
|
|
80
|
+
meta: {
|
|
81
|
+
type: "problem",
|
|
82
|
+
docs: { description: "A flow polls for the state it needs, with a deadline. It never sleeps." },
|
|
83
|
+
schema: [],
|
|
84
|
+
messages: {
|
|
85
|
+
sleep:
|
|
86
|
+
"`{{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.",
|
|
87
|
+
timer:
|
|
88
|
+
"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.",
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
create(context) {
|
|
92
|
+
if (!isE2eSpec(context.filename || context.getFilename())) return {}
|
|
93
|
+
return {
|
|
94
|
+
CallExpression(node) {
|
|
95
|
+
const callee = node.callee
|
|
96
|
+
if (!callee || callee.type !== "Identifier" || !SLEEPERS.has(callee.name)) return
|
|
97
|
+
// A `setTimeout` wrapped in `new Promise` is reported once, as `timer`, by the branch below.
|
|
98
|
+
// Reporting it here as well would put two findings on one sleep and teach nobody anything.
|
|
99
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
100
|
+
if (current.type === "NewExpression" && current.callee && current.callee.name === "Promise") return
|
|
101
|
+
}
|
|
102
|
+
context.report({ node, messageId: "sleep", data: { name: callee.name } })
|
|
103
|
+
},
|
|
104
|
+
NewExpression(node) {
|
|
105
|
+
if (!node.callee || node.callee.name !== "Promise") return
|
|
106
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
107
|
+
if (/setTimeout/.test(source.getText(node))) context.report({ node, messageId: "timer" })
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// -- E2E-7 ----------------------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/** A step asserts one outcome. A branch means the test is prepared for either, which is no assertion. */
|
|
116
|
+
export const noBranchInFlowStep = {
|
|
117
|
+
meta: {
|
|
118
|
+
type: "problem",
|
|
119
|
+
docs: { description: "A flow step takes no branch: it asserts the one outcome the business promises." },
|
|
120
|
+
schema: [],
|
|
121
|
+
messages: {
|
|
122
|
+
branch:
|
|
123
|
+
"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.",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
create(context) {
|
|
127
|
+
if (!isE2eSpec(context.filename || context.getFilename())) return {}
|
|
128
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
129
|
+
|
|
130
|
+
/** True when this node sits inside the callback of an `it(...)`. */
|
|
131
|
+
const insideStep = (node) => {
|
|
132
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
133
|
+
if (current.type !== "CallExpression") continue
|
|
134
|
+
const callee = current.callee
|
|
135
|
+
const name = callee && (callee.name || (callee.object && callee.object.name))
|
|
136
|
+
if (name === "it" || name === "test") return true
|
|
137
|
+
}
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const report = (node) => {
|
|
142
|
+
if (insideStep(node)) context.report({ node, messageId: "branch" })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
IfStatement: report,
|
|
147
|
+
ConditionalExpression: report,
|
|
148
|
+
SwitchStatement: report,
|
|
149
|
+
LogicalExpression(node) {
|
|
150
|
+
// `a && b` used as a STATEMENT is a hidden if; the same operator inside an assertion is not
|
|
151
|
+
if (node.parent && node.parent.type === "ExpressionStatement") report(node)
|
|
152
|
+
// touching the source keeps the helper honest if the shape ever changes
|
|
153
|
+
void source
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The rules this law contributes to the plugin. */
|
|
160
|
+
export const rules = {
|
|
161
|
+
"e2e-uses-production-transport": e2eUsesProductionTransport,
|
|
162
|
+
"no-sleep-in-flow": noSleepInFlow,
|
|
163
|
+
"no-branch-in-flow-step": noBranchInFlowStep,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
168
|
+
*
|
|
169
|
+
* Both are exact and both fire on a syntactic shape rather than on a judgement, so neither carries
|
|
170
|
+
* the false-positive risk that would justify adopting it at `warn`.
|
|
171
|
+
*/
|
|
172
|
+
export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-be/${name}`, "error"]))
|
package/event-delivery.mjs
CHANGED
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
2
|
-
|
|
3
|
-
/** The NATS bridge rejects self-echo and claims redelivery before local fan-out. */
|
|
4
|
-
export const natsBridgeDeliveryContract = {
|
|
5
|
-
meta: {
|
|
6
|
-
type: "problem",
|
|
7
|
-
docs: { description: "The central NATS bridge preserves cross-instance delivery invariants." },
|
|
8
|
-
schema: [],
|
|
9
|
-
messages: {
|
|
10
|
-
origin: "Drop envelopes whose producer id matches this instance before local emit.",
|
|
11
|
-
digest: "Claim/check the envelope digest before local emit so redelivery is idempotent.",
|
|
12
|
-
},
|
|
13
|
-
},
|
|
14
|
-
create(context) {
|
|
15
|
-
if (!normalizePath(context.filename || context.getFilename()).endsWith(
|
|
16
|
-
"/event/nats/nats-bridge.service.ts",
|
|
17
|
-
)) return {}
|
|
18
|
-
return {
|
|
19
|
-
"Program:exit"(node) {
|
|
20
|
-
const source = context.sourceCode || context.getSourceCode()
|
|
21
|
-
const text = source.getText()
|
|
22
|
-
const originIndex = text.search(/parsed\.id\s*===\s*this\.instanceService\.getId\(\)/)
|
|
23
|
-
const digestIndex = text.indexOf("parsed.digest")
|
|
24
|
-
const emitIndex = text.indexOf("this.eventEmitter.emit")
|
|
25
|
-
if (originIndex < 0 || emitIndex < 0 || originIndex > emitIndex) {
|
|
26
|
-
context.report({ node, messageId: "origin" })
|
|
27
|
-
}
|
|
28
|
-
if (digestIndex < 0 || emitIndex < 0 || digestIndex > emitIndex) {
|
|
29
|
-
context.report({ node, messageId: "digest" })
|
|
30
|
-
}
|
|
31
|
-
},
|
|
32
|
-
}
|
|
33
|
-
},
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export const rules = { "nats-bridge-delivery-contract": natsBridgeDeliveryContract }
|
|
37
|
-
|
|
38
|
-
export const recommended = {
|
|
39
|
-
"starci-be/nats-bridge-delivery-contract": "error",
|
|
40
|
-
}
|
|
1
|
+
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
2
|
+
|
|
3
|
+
/** The NATS bridge rejects self-echo and claims redelivery before local fan-out. */
|
|
4
|
+
export const natsBridgeDeliveryContract = {
|
|
5
|
+
meta: {
|
|
6
|
+
type: "problem",
|
|
7
|
+
docs: { description: "The central NATS bridge preserves cross-instance delivery invariants." },
|
|
8
|
+
schema: [],
|
|
9
|
+
messages: {
|
|
10
|
+
origin: "Drop envelopes whose producer id matches this instance before local emit.",
|
|
11
|
+
digest: "Claim/check the envelope digest before local emit so redelivery is idempotent.",
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
create(context) {
|
|
15
|
+
if (!normalizePath(context.filename || context.getFilename()).endsWith(
|
|
16
|
+
"/event/nats/nats-bridge.service.ts",
|
|
17
|
+
)) return {}
|
|
18
|
+
return {
|
|
19
|
+
"Program:exit"(node) {
|
|
20
|
+
const source = context.sourceCode || context.getSourceCode()
|
|
21
|
+
const text = source.getText()
|
|
22
|
+
const originIndex = text.search(/parsed\.id\s*===\s*this\.instanceService\.getId\(\)/)
|
|
23
|
+
const digestIndex = text.indexOf("parsed.digest")
|
|
24
|
+
const emitIndex = text.indexOf("this.eventEmitter.emit")
|
|
25
|
+
if (originIndex < 0 || emitIndex < 0 || originIndex > emitIndex) {
|
|
26
|
+
context.report({ node, messageId: "origin" })
|
|
27
|
+
}
|
|
28
|
+
if (digestIndex < 0 || emitIndex < 0 || digestIndex > emitIndex) {
|
|
29
|
+
context.report({ node, messageId: "digest" })
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const rules = { "nats-bridge-delivery-contract": natsBridgeDeliveryContract }
|
|
37
|
+
|
|
38
|
+
export const recommended = {
|
|
39
|
+
"starci-be/nats-bridge-delivery-contract": "error",
|
|
40
|
+
}
|
package/index.mjs
CHANGED
|
@@ -54,12 +54,29 @@ const CONTRIBUTIONS = [
|
|
|
54
54
|
/** Every gathered law. */
|
|
55
55
|
export const lawOwners = CONTRIBUTIONS.map((entry) => entry.law)
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Every (law, rule name) pair as DECLARED, before any collapsing.
|
|
59
|
+
*
|
|
60
|
+
* This exists because `ruleOwners` and `rules` below are both built with `Object.fromEntries`, which
|
|
61
|
+
* silently keeps the last writer when two laws declare one name. A test that walks either of them
|
|
62
|
+
* therefore cannot see a collision: the duplicate is already gone by the time it looks, so every
|
|
63
|
+
* name appears exactly once BY CONSTRUCTION and the check passes forever while being blind.
|
|
64
|
+
*
|
|
65
|
+
* That is exactly what happened - three back-end rules were declared by two laws each, one copy was
|
|
66
|
+
* discarded on import, and the guard reported green the whole time. A gate that cannot fail is
|
|
67
|
+
* indistinguishable from no gate, so the raw declarations are published here and the guard walks
|
|
68
|
+
* these instead.
|
|
69
|
+
*/
|
|
70
|
+
export const ruleDeclarations = CONTRIBUTIONS.flatMap((entry) =>
|
|
71
|
+
Object.keys(entry.rules).map((name) => ({ law: entry.law, name })),
|
|
72
|
+
)
|
|
73
|
+
|
|
57
74
|
/**
|
|
58
75
|
* Which law declares each rule.
|
|
59
76
|
*
|
|
60
77
|
* Exported because a failing rule is a question about a LAW, and the shortest path from a build log
|
|
61
|
-
* to the document that explains it is this map. It
|
|
62
|
-
* laws claim one name.
|
|
78
|
+
* to the document that explains it is this map. It answers "who owns this name" for a name that
|
|
79
|
+
* SHIPPED; proving no two laws claim one name is `ruleDeclarations`' job, not this map's.
|
|
63
80
|
*/
|
|
64
81
|
export const ruleOwners = Object.fromEntries(
|
|
65
82
|
CONTRIBUTIONS.flatMap((entry) => Object.keys(entry.rules).map((name) => [name, entry.law])),
|
package/package.json
CHANGED
package/testing.mjs
CHANGED
|
@@ -171,50 +171,6 @@ export const noModelCallInE2e = {
|
|
|
171
171
|
},
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
// -- TESTING-3 -------------------------------------------------------------------------------------
|
|
175
|
-
|
|
176
|
-
/** CQRS application buses are internal dispatchers, never production entry points. */
|
|
177
|
-
const APPLICATION_BUSES = new Set(["CommandBus", "QueryBus", "EventBus"])
|
|
178
|
-
|
|
179
|
-
/** Methods whose direct invocation skips the transport owned by production. */
|
|
180
|
-
const INTERNAL_ENTRY_METHODS = new Set(["execute", "process"])
|
|
181
|
-
|
|
182
|
-
/** An e2e enters through a production transport rather than an application object. */
|
|
183
|
-
export const e2eUsesProductionTransport = {
|
|
184
|
-
meta: {
|
|
185
|
-
type: "problem",
|
|
186
|
-
docs: { description: "An e2e enters through GraphQL, HTTP, socket, broker or scheduler transport." },
|
|
187
|
-
schema: [],
|
|
188
|
-
messages: {
|
|
189
|
-
busImport:
|
|
190
|
-
"`{{name}}` is an application dispatcher, not a production transport. An e2e must enter through GraphQL, HTTP, a real socket, a broker message or the scheduler boundary so routing, guards, validation and serialization are part of the proof.",
|
|
191
|
-
direct:
|
|
192
|
-
"Direct `.{{method}}()` invocation skips the production transport and starts inside the application. Move this decision/wiring test to the integration lane, or drive the flow through GraphQL, HTTP, a real socket, broker or scheduler boundary.",
|
|
193
|
-
},
|
|
194
|
-
},
|
|
195
|
-
create(context) {
|
|
196
|
-
if (!isE2eSpec(context.filename || context.getFilename())) return {}
|
|
197
|
-
return {
|
|
198
|
-
ImportDeclaration(node) {
|
|
199
|
-
if (!node.source || node.source.value !== "@nestjs/cqrs") return
|
|
200
|
-
for (const specifier of node.specifiers) {
|
|
201
|
-
if (specifier.type !== "ImportSpecifier") continue
|
|
202
|
-
const imported = specifier.imported && (specifier.imported.name || specifier.imported.value)
|
|
203
|
-
if (!APPLICATION_BUSES.has(imported)) continue
|
|
204
|
-
context.report({ node: specifier, messageId: "busImport", data: { name: imported } })
|
|
205
|
-
}
|
|
206
|
-
},
|
|
207
|
-
CallExpression(node) {
|
|
208
|
-
const callee = node.callee
|
|
209
|
-
if (!callee || callee.type !== "MemberExpression" || callee.computed) return
|
|
210
|
-
const method = callee.property && callee.property.name
|
|
211
|
-
if (!INTERNAL_ENTRY_METHODS.has(method)) return
|
|
212
|
-
context.report({ node: callee.property, messageId: "direct", data: { method } })
|
|
213
|
-
},
|
|
214
|
-
}
|
|
215
|
-
},
|
|
216
|
-
}
|
|
217
|
-
|
|
218
174
|
// -- TESTING-10 ------------------------------------------------------------------------------------
|
|
219
175
|
|
|
220
176
|
/** Official provider clients that a harness may call without a house routing layer. */
|
|
@@ -326,7 +282,6 @@ export const rules = {
|
|
|
326
282
|
"no-call-only-spec": noCallOnlySpec,
|
|
327
283
|
"e2e-asserts-persisted-state": e2eAssertsPersistedState,
|
|
328
284
|
"no-model-call-in-e2e": noModelCallInE2e,
|
|
329
|
-
"e2e-uses-production-transport": e2eUsesProductionTransport,
|
|
330
285
|
"harness-calls-provider-directly": harnessCallsProviderDirectly,
|
|
331
286
|
}
|
|
332
287
|
|
|
@@ -348,6 +303,5 @@ export const recommended = {
|
|
|
348
303
|
"starci-be/no-call-only-spec": "error", // no=0 of 182 - burned down from 1
|
|
349
304
|
"starci-be/e2e-asserts-persisted-state": "error", // no=0 of 47 - burned down from 1
|
|
350
305
|
"starci-be/no-model-call-in-e2e": "error", // no=0 of 47
|
|
351
|
-
"starci-be/e2e-uses-production-transport": "error",
|
|
352
306
|
"starci-be/harness-calls-provider-directly": "error",
|
|
353
307
|
}
|
package/transport.mjs
CHANGED
|
@@ -1,103 +1,103 @@
|
|
|
1
|
-
const normalize = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
2
|
-
|
|
3
|
-
/** The decorator's own name, whether it was written `@Controller` or `@Controller("x")`. */
|
|
4
|
-
const decoratorName = (node) => {
|
|
5
|
-
const expression = node.expression
|
|
6
|
-
if (!expression) return null
|
|
7
|
-
if (expression.type === "Identifier") return expression.name
|
|
8
|
-
if (expression.type === "CallExpression" && expression.callee.type === "Identifier") {
|
|
9
|
-
return expression.callee.name
|
|
10
|
-
}
|
|
11
|
-
return null
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/** The route string, or "" for `@Controller()` and for a path given as an object. */
|
|
15
|
-
const routeOf = (node) => {
|
|
16
|
-
const expression = node.expression
|
|
17
|
-
if (!expression || expression.type !== "CallExpression") return ""
|
|
18
|
-
const first = expression.arguments[0]
|
|
19
|
-
if (first && first.type === "Literal" && typeof first.value === "string") return first.value
|
|
20
|
-
return ""
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Which of the four sanctioned reasons this file shows, if any.
|
|
25
|
-
*
|
|
26
|
-
* READ OFF THE FILE, NOT OFF A LIST. A registry of blessed routes rots the moment somebody adds a
|
|
27
|
-
* route and forgets it; the evidence here is the same evidence a reader would use.
|
|
28
|
-
*/
|
|
29
|
-
const reasonFor = (route, filename, text) => {
|
|
30
|
-
const path = normalize(filename)
|
|
31
|
-
// a probe answers when the app is degraded, before the feature layer is necessarily up
|
|
32
|
-
if (/^healthz?$/.test(route) || /\/health(z)?[./]/.test(path)) return "probe"
|
|
33
|
-
// something outside posts to a URL we gave it, and it will never send a GraphQL document
|
|
34
|
-
if (/webhook/i.test(route) || /webhook/i.test(path)) return "external"
|
|
35
|
-
// bytes, not fields: multipart in, stream out
|
|
36
|
-
if (/FileInterceptor|FilesInterceptor|AnyFilesInterceptor|StreamableFile|@Res\s*\(|createReadStream/.test(text)) {
|
|
37
|
-
return "bytes"
|
|
38
|
-
}
|
|
39
|
-
// a machine registering itself, with no user session to carry
|
|
40
|
-
if (/^(pods|internal|agents)\//.test(route)) return "machine"
|
|
41
|
-
// an identity that is not a user session - a platform operator or a service token
|
|
42
|
-
if (/^api\/ops(\/|$)/.test(route) || /Operator[A-Za-z]*Guard|ServiceToken|OPS_TOKEN/.test(text)) {
|
|
43
|
-
return "operator"
|
|
44
|
-
}
|
|
45
|
-
return null
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** A REST door exists only where GraphQL cannot go, and the file has to show which case it is. */
|
|
49
|
-
export const restDoorNeedsAReason = {
|
|
50
|
-
meta: {
|
|
51
|
-
type: "problem",
|
|
52
|
-
docs: { description: "A @Controller is permitted only where GraphQL cannot serve." },
|
|
53
|
-
schema: [],
|
|
54
|
-
messages: {
|
|
55
|
-
unjustified:
|
|
56
|
-
"This REST door shows none of the four reasons a door may not be GraphQL: an external system posting to a fixed URL, a payload of bytes rather than fields, a machine with no user session, or an identity that is not one. A plain JSON read belongs in the GraphQL schema, where the client already is.",
|
|
57
|
-
},
|
|
58
|
-
},
|
|
59
|
-
create(context) {
|
|
60
|
-
const filename = context.filename || context.getFilename()
|
|
61
|
-
const text = (context.sourceCode || context.getSourceCode()).getText()
|
|
62
|
-
return {
|
|
63
|
-
Decorator(node) {
|
|
64
|
-
if (decoratorName(node) !== "Controller") return
|
|
65
|
-
if (reasonFor(routeOf(node), filename, text)) return
|
|
66
|
-
context.report({ node, messageId: "unjustified" })
|
|
67
|
-
},
|
|
68
|
-
}
|
|
69
|
-
},
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** A door is a door whatever its transport, and every door in this repository lives in features/. */
|
|
73
|
-
export const doorLivesInFeatures = {
|
|
74
|
-
meta: {
|
|
75
|
-
type: "problem",
|
|
76
|
-
docs: { description: "HTTP doors sit under features/, beside the GraphQL ones." },
|
|
77
|
-
schema: [],
|
|
78
|
-
messages: {
|
|
79
|
-
wrongTree:
|
|
80
|
-
"A @Controller under src/modules/ is a door parked among the capabilities it calls, where it reads as one and gets imported like one. Every GraphQL door in this repository already lives under features/; transport was never what decided the address.",
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
create(context) {
|
|
84
|
-
const path = normalize(context.filename || context.getFilename())
|
|
85
|
-
if (!/\/src\/modules\//.test(path)) return {}
|
|
86
|
-
return {
|
|
87
|
-
Decorator(node) {
|
|
88
|
-
if (decoratorName(node) !== "Controller") return
|
|
89
|
-
context.report({ node, messageId: "wrongTree" })
|
|
90
|
-
},
|
|
91
|
-
}
|
|
92
|
-
},
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export const rules = {
|
|
96
|
-
"rest-door-needs-a-reason": restDoorNeedsAReason,
|
|
97
|
-
"door-lives-in-features": doorLivesInFeatures,
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export const recommended = {
|
|
101
|
-
"starci-be/rest-door-needs-a-reason": "error",
|
|
102
|
-
"starci-be/door-lives-in-features": "error",
|
|
103
|
-
}
|
|
1
|
+
const normalize = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
2
|
+
|
|
3
|
+
/** The decorator's own name, whether it was written `@Controller` or `@Controller("x")`. */
|
|
4
|
+
const decoratorName = (node) => {
|
|
5
|
+
const expression = node.expression
|
|
6
|
+
if (!expression) return null
|
|
7
|
+
if (expression.type === "Identifier") return expression.name
|
|
8
|
+
if (expression.type === "CallExpression" && expression.callee.type === "Identifier") {
|
|
9
|
+
return expression.callee.name
|
|
10
|
+
}
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The route string, or "" for `@Controller()` and for a path given as an object. */
|
|
15
|
+
const routeOf = (node) => {
|
|
16
|
+
const expression = node.expression
|
|
17
|
+
if (!expression || expression.type !== "CallExpression") return ""
|
|
18
|
+
const first = expression.arguments[0]
|
|
19
|
+
if (first && first.type === "Literal" && typeof first.value === "string") return first.value
|
|
20
|
+
return ""
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Which of the four sanctioned reasons this file shows, if any.
|
|
25
|
+
*
|
|
26
|
+
* READ OFF THE FILE, NOT OFF A LIST. A registry of blessed routes rots the moment somebody adds a
|
|
27
|
+
* route and forgets it; the evidence here is the same evidence a reader would use.
|
|
28
|
+
*/
|
|
29
|
+
const reasonFor = (route, filename, text) => {
|
|
30
|
+
const path = normalize(filename)
|
|
31
|
+
// a probe answers when the app is degraded, before the feature layer is necessarily up
|
|
32
|
+
if (/^healthz?$/.test(route) || /\/health(z)?[./]/.test(path)) return "probe"
|
|
33
|
+
// something outside posts to a URL we gave it, and it will never send a GraphQL document
|
|
34
|
+
if (/webhook/i.test(route) || /webhook/i.test(path)) return "external"
|
|
35
|
+
// bytes, not fields: multipart in, stream out
|
|
36
|
+
if (/FileInterceptor|FilesInterceptor|AnyFilesInterceptor|StreamableFile|@Res\s*\(|createReadStream/.test(text)) {
|
|
37
|
+
return "bytes"
|
|
38
|
+
}
|
|
39
|
+
// a machine registering itself, with no user session to carry
|
|
40
|
+
if (/^(pods|internal|agents)\//.test(route)) return "machine"
|
|
41
|
+
// an identity that is not a user session - a platform operator or a service token
|
|
42
|
+
if (/^api\/ops(\/|$)/.test(route) || /Operator[A-Za-z]*Guard|ServiceToken|OPS_TOKEN/.test(text)) {
|
|
43
|
+
return "operator"
|
|
44
|
+
}
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A REST door exists only where GraphQL cannot go, and the file has to show which case it is. */
|
|
49
|
+
export const restDoorNeedsAReason = {
|
|
50
|
+
meta: {
|
|
51
|
+
type: "problem",
|
|
52
|
+
docs: { description: "A @Controller is permitted only where GraphQL cannot serve." },
|
|
53
|
+
schema: [],
|
|
54
|
+
messages: {
|
|
55
|
+
unjustified:
|
|
56
|
+
"This REST door shows none of the four reasons a door may not be GraphQL: an external system posting to a fixed URL, a payload of bytes rather than fields, a machine with no user session, or an identity that is not one. A plain JSON read belongs in the GraphQL schema, where the client already is.",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
create(context) {
|
|
60
|
+
const filename = context.filename || context.getFilename()
|
|
61
|
+
const text = (context.sourceCode || context.getSourceCode()).getText()
|
|
62
|
+
return {
|
|
63
|
+
Decorator(node) {
|
|
64
|
+
if (decoratorName(node) !== "Controller") return
|
|
65
|
+
if (reasonFor(routeOf(node), filename, text)) return
|
|
66
|
+
context.report({ node, messageId: "unjustified" })
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A door is a door whatever its transport, and every door in this repository lives in features/. */
|
|
73
|
+
export const doorLivesInFeatures = {
|
|
74
|
+
meta: {
|
|
75
|
+
type: "problem",
|
|
76
|
+
docs: { description: "HTTP doors sit under features/, beside the GraphQL ones." },
|
|
77
|
+
schema: [],
|
|
78
|
+
messages: {
|
|
79
|
+
wrongTree:
|
|
80
|
+
"A @Controller under src/modules/ is a door parked among the capabilities it calls, where it reads as one and gets imported like one. Every GraphQL door in this repository already lives under features/; transport was never what decided the address.",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
create(context) {
|
|
84
|
+
const path = normalize(context.filename || context.getFilename())
|
|
85
|
+
if (!/\/src\/modules\//.test(path)) return {}
|
|
86
|
+
return {
|
|
87
|
+
Decorator(node) {
|
|
88
|
+
if (decoratorName(node) !== "Controller") return
|
|
89
|
+
context.report({ node, messageId: "wrongTree" })
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const rules = {
|
|
96
|
+
"rest-door-needs-a-reason": restDoorNeedsAReason,
|
|
97
|
+
"door-lives-in-features": doorLivesInFeatures,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const recommended = {
|
|
101
|
+
"starci-be/rest-door-needs-a-reason": "error",
|
|
102
|
+
"starci-be/door-lives-in-features": "error",
|
|
103
|
+
}
|