@starci/eslint-canon-be 1.2.2 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +46 -7
- package/async-discipline.mjs +83 -0
- package/authorization.mjs +106 -106
- package/comments.mjs +7 -55
- package/config-owner.mjs +252 -0
- package/cqrs.mjs +7 -141
- package/data-access.mjs +151 -5
- package/default-deny.mjs +116 -0
- package/e2e-flow.mjs +222 -222
- package/error-handling.mjs +207 -0
- package/idempotency.mjs +101 -0
- package/index.mjs +34 -8
- package/input-bounds.mjs +84 -0
- package/lib/ast.mjs +81 -0
- package/lib/config.mjs +49 -0
- package/lib/path.mjs +15 -0
- package/lib/slots.mjs +83 -0
- package/log-safety.mjs +148 -0
- package/module-layering.mjs +87 -105
- package/module-shape.mjs +202 -0
- package/observability.mjs +275 -275
- package/package.json +4 -1
- package/query-safety.mjs +274 -0
- package/resilience.mjs +178 -0
- package/schema-authority.mjs +187 -0
- package/size-budget.mjs +106 -0
- package/spec-quality.mjs +132 -0
- package/suppression.mjs +60 -0
- package/temporal.mjs +71 -0
- package/testing.mjs +31 -19
- package/transport.mjs +168 -168
- package/type-safety.mjs +98 -57
- package/user-copy.mjs +106 -0
- package/cdc.mjs +0 -150
- package/event-delivery.mjs +0 -93
- package/exception-identity.mjs +0 -200
- package/exceptions.mjs +0 -262
package/transport.mjs
CHANGED
|
@@ -1,168 +1,168 @@
|
|
|
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
|
-
/** An import specifier's alias prefix for a door. */
|
|
96
|
-
const FEATURES_ALIAS = "@features/"
|
|
97
|
-
|
|
98
|
-
/** A relative or deep specifier climbing into `features/`, once the alias form is ruled out. */
|
|
99
|
-
const FEATURES_SEGMENT = /(^|\/)features\//
|
|
100
|
-
|
|
101
|
-
/** Whether an import specifier reaches into `features/`, by alias or by path. */
|
|
102
|
-
const reachesFeatures = (specifier) => {
|
|
103
|
-
if (typeof specifier !== "string") return false
|
|
104
|
-
if (specifier.startsWith(FEATURES_ALIAS)) return true
|
|
105
|
-
return FEATURES_SEGMENT.test(specifier)
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Rule 6: "A capability stays under `modules/` and is called by a door, never reached directly."
|
|
110
|
-
* A door under `features/` importing a capability under `modules/` is the entire shape TRANSPORT-3
|
|
111
|
-
* sets up, and this rule is not that edge - it fires only the other way: a file living under
|
|
112
|
-
* `modules/` reaching back into `features/`. That reversal makes the capability the caller and the
|
|
113
|
-
* door the thing being reached, which is backwards from what a capability is - it is CALLED, and it
|
|
114
|
-
* stays ignorant of who calls it. One capability importing another capability under `modules/` is
|
|
115
|
-
* untouched here; this rule polices one edge, not the whole graph.
|
|
116
|
-
*/
|
|
117
|
-
export const noCapabilityImportsFeatures = {
|
|
118
|
-
meta: {
|
|
119
|
-
type: "problem",
|
|
120
|
-
docs: {
|
|
121
|
-
description:
|
|
122
|
-
"TRANSPORT Rule 6: a capability under modules/ never imports from features/ - a door calls into a capability, never the other way round.",
|
|
123
|
-
},
|
|
124
|
-
schema: [],
|
|
125
|
-
messages: {
|
|
126
|
-
reversed:
|
|
127
|
-
"`{{specifier}}` reaches from a capability under modules/ into features/, where doors live. A capability is CALLED by a door and stays ignorant of who calls it; importing back into features/ makes the capability the caller, which is the exact edge this law forbids. Move the shared code under modules/, or invert the call so the door reaches in.",
|
|
128
|
-
},
|
|
129
|
-
},
|
|
130
|
-
create(context) {
|
|
131
|
-
const path = normalize(context.filename || context.getFilename())
|
|
132
|
-
if (!/\/src\/modules\//.test(path)) return {}
|
|
133
|
-
const check = (node, specifier) => {
|
|
134
|
-
if (reachesFeatures(specifier)) context.report({ node, messageId: "reversed", data: { specifier } })
|
|
135
|
-
}
|
|
136
|
-
return {
|
|
137
|
-
ImportDeclaration(node) {
|
|
138
|
-
check(node.source, node.source.value)
|
|
139
|
-
},
|
|
140
|
-
ExportNamedDeclaration(node) {
|
|
141
|
-
if (node.source) check(node.source, node.source.value)
|
|
142
|
-
},
|
|
143
|
-
ExportAllDeclaration(node) {
|
|
144
|
-
if (node.source) check(node.source, node.source.value)
|
|
145
|
-
},
|
|
146
|
-
}
|
|
147
|
-
},
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export const rules = {
|
|
151
|
-
"rest-door-needs-a-reason": restDoorNeedsAReason,
|
|
152
|
-
"door-lives-in-features": doorLivesInFeatures,
|
|
153
|
-
"no-capability-imports-features": noCapabilityImportsFeatures,
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* `no-capability-imports-features` measures at 9 offenders across 6 files in the reference
|
|
158
|
-
* repository (`src/**`, probed against source directly since the published package predates this
|
|
159
|
-
* rule) -- every one a capability under `modules/bussiness/**` importing a single shared helper
|
|
160
|
-
* published under `@features/api/processors/ai/shared/xp/write-coin-history`. That is standing debt,
|
|
161
|
-
* not zero, so it ships at `warn` with the count on record, matching this module's own Exceptions
|
|
162
|
-
* section: measured debt arrives at `warn` and flips to `error` once it reaches zero.
|
|
163
|
-
*/
|
|
164
|
-
export const recommended = {
|
|
165
|
-
"starci-be/rest-door-needs-a-reason": "error",
|
|
166
|
-
"starci-be/door-lives-in-features": "error",
|
|
167
|
-
"starci-be/no-capability-imports-features": "warn",
|
|
168
|
-
}
|
|
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
|
+
/** An import specifier's alias prefix for a door. */
|
|
96
|
+
const FEATURES_ALIAS = "@features/"
|
|
97
|
+
|
|
98
|
+
/** A relative or deep specifier climbing into `features/`, once the alias form is ruled out. */
|
|
99
|
+
const FEATURES_SEGMENT = /(^|\/)features\//
|
|
100
|
+
|
|
101
|
+
/** Whether an import specifier reaches into `features/`, by alias or by path. */
|
|
102
|
+
const reachesFeatures = (specifier) => {
|
|
103
|
+
if (typeof specifier !== "string") return false
|
|
104
|
+
if (specifier.startsWith(FEATURES_ALIAS)) return true
|
|
105
|
+
return FEATURES_SEGMENT.test(specifier)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Rule 6: "A capability stays under `modules/` and is called by a door, never reached directly."
|
|
110
|
+
* A door under `features/` importing a capability under `modules/` is the entire shape TRANSPORT-3
|
|
111
|
+
* sets up, and this rule is not that edge - it fires only the other way: a file living under
|
|
112
|
+
* `modules/` reaching back into `features/`. That reversal makes the capability the caller and the
|
|
113
|
+
* door the thing being reached, which is backwards from what a capability is - it is CALLED, and it
|
|
114
|
+
* stays ignorant of who calls it. One capability importing another capability under `modules/` is
|
|
115
|
+
* untouched here; this rule polices one edge, not the whole graph.
|
|
116
|
+
*/
|
|
117
|
+
export const noCapabilityImportsFeatures = {
|
|
118
|
+
meta: {
|
|
119
|
+
type: "problem",
|
|
120
|
+
docs: {
|
|
121
|
+
description:
|
|
122
|
+
"TRANSPORT Rule 6: a capability under modules/ never imports from features/ - a door calls into a capability, never the other way round.",
|
|
123
|
+
},
|
|
124
|
+
schema: [],
|
|
125
|
+
messages: {
|
|
126
|
+
reversed:
|
|
127
|
+
"`{{specifier}}` reaches from a capability under modules/ into features/, where doors live. A capability is CALLED by a door and stays ignorant of who calls it; importing back into features/ makes the capability the caller, which is the exact edge this law forbids. Move the shared code under modules/, or invert the call so the door reaches in.",
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
create(context) {
|
|
131
|
+
const path = normalize(context.filename || context.getFilename())
|
|
132
|
+
if (!/\/src\/modules\//.test(path)) return {}
|
|
133
|
+
const check = (node, specifier) => {
|
|
134
|
+
if (reachesFeatures(specifier)) context.report({ node, messageId: "reversed", data: { specifier } })
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
ImportDeclaration(node) {
|
|
138
|
+
check(node.source, node.source.value)
|
|
139
|
+
},
|
|
140
|
+
ExportNamedDeclaration(node) {
|
|
141
|
+
if (node.source) check(node.source, node.source.value)
|
|
142
|
+
},
|
|
143
|
+
ExportAllDeclaration(node) {
|
|
144
|
+
if (node.source) check(node.source, node.source.value)
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const rules = {
|
|
151
|
+
"rest-door-needs-a-reason": restDoorNeedsAReason,
|
|
152
|
+
"door-lives-in-features": doorLivesInFeatures,
|
|
153
|
+
"no-capability-imports-features": noCapabilityImportsFeatures,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `no-capability-imports-features` measures at 9 offenders across 6 files in the reference
|
|
158
|
+
* repository (`src/**`, probed against source directly since the published package predates this
|
|
159
|
+
* rule) -- every one a capability under `modules/bussiness/**` importing a single shared helper
|
|
160
|
+
* published under `@features/api/processors/ai/shared/xp/write-coin-history`. That is standing debt,
|
|
161
|
+
* not zero, so it ships at `warn` with the count on record, matching this module's own Exceptions
|
|
162
|
+
* section: measured debt arrives at `warn` and flips to `error` once it reaches zero.
|
|
163
|
+
*/
|
|
164
|
+
export const recommended = {
|
|
165
|
+
"starci-be/rest-door-needs-a-reason": "error",
|
|
166
|
+
"starci-be/door-lives-in-features": "error",
|
|
167
|
+
"starci-be/no-capability-imports-features": "warn",
|
|
168
|
+
}
|
package/type-safety.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* (TYPE-3, rule 4, extended; see the export below for its `recommended` level and the measurement
|
|
15
15
|
* that set it), the const enum
|
|
16
16
|
* (TYPE-4, rule 5), the unguarded cast off an `unknown` value (TYPE-1's second half, rule 2), and
|
|
17
|
-
*
|
|
17
|
+
* a way of switching the
|
|
18
18
|
* compiler off -- or of leaving it turned further down than the law asks -- that looks locally
|
|
19
19
|
* reasonable, which is exactly the class a rule is for.
|
|
20
20
|
*
|
|
@@ -28,7 +28,7 @@ import { normalizePath } from "./lib/path.mjs"
|
|
|
28
28
|
/** The spec family and the test tree may build a deliberately wrong value on purpose. */
|
|
29
29
|
const isTestFile = (filename) => {
|
|
30
30
|
const file = normalizePath(filename)
|
|
31
|
-
return /\.(?:spec|test|e2e-spec
|
|
31
|
+
return /\.(?:spec|test|e2e-spec)\.ts$/.test(file) || file.includes("/src/tests/")
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/** A parameter property (`private readonly x: T`) wraps the parameter it declares. */
|
|
@@ -387,77 +387,114 @@ export const noConstEnum = {
|
|
|
387
387
|
},
|
|
388
388
|
}
|
|
389
389
|
|
|
390
|
-
// --
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
390
|
+
// -- R72 -------------------------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
/** `as never` asserts that a value is of no type at all, which satisfies every parameter and checks nothing. */
|
|
393
|
+
export const noNeverCast = {
|
|
394
|
+
meta: {
|
|
395
|
+
type: "problem",
|
|
396
|
+
docs: { description: "No `x as never` outside the test lanes." },
|
|
397
|
+
schema: [],
|
|
398
|
+
messages: {
|
|
399
|
+
neverCast:
|
|
400
|
+
"`as never` makes this value assignable to anything, so the compiler stops checking the call it is passed to. It hides a real mismatch between the value and the parameter. Give the value the type the parameter wants, narrow it with a guard, or fix the signature.",
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
create(context) {
|
|
404
|
+
if (isTestFile(context.filename || context.getFilename())) return {}
|
|
405
|
+
return {
|
|
406
|
+
TSAsExpression(node) {
|
|
407
|
+
if (node.typeAnnotation?.type === "TSNeverKeyword") context.report({ node, messageId: "neverCast" })
|
|
408
|
+
},
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** `x!` claims a value is present without checking. */
|
|
414
|
+
export const noNonNullAssertion = {
|
|
415
|
+
meta: {
|
|
416
|
+
type: "problem",
|
|
417
|
+
docs: { description: "No non-null assertion (`x!`) outside the test lanes." },
|
|
418
|
+
schema: [],
|
|
419
|
+
messages: {
|
|
420
|
+
nonNull:
|
|
421
|
+
"`!` claims this value is present and checks nothing; when it is not, the failure is a `TypeError` far from here. Narrow with a check that returns or throws a typed error, or change the type so absence is impossible.",
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
create(context) {
|
|
425
|
+
if (isTestFile(context.filename || context.getFilename())) return {}
|
|
426
|
+
return {
|
|
427
|
+
TSNonNullExpression(node) {
|
|
428
|
+
context.report({ node, messageId: "nonNull" })
|
|
429
|
+
},
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
}
|
|
404
433
|
|
|
405
|
-
// --
|
|
434
|
+
// -- R75 -------------------------------------------------------------------------------------------
|
|
406
435
|
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
"no-double-cast",
|
|
410
|
-
"no-inline-param-type",
|
|
411
|
-
"no-inline-object-type",
|
|
412
|
-
"no-const-enum",
|
|
413
|
-
"no-unguarded-unknown-cast",
|
|
414
|
-
]
|
|
436
|
+
const HANDLER_DECORATORS = new Set(["Query", "Mutation", "Subscription", "ResolveField", "Get", "Post", "Put", "Patch", "Delete", "MessagePattern", "EventPattern", "Cron", "Interval"])
|
|
437
|
+
const SURFACE_CLASS_DECORATORS = new Set(["Injectable", "Resolver", "Controller"])
|
|
415
438
|
|
|
416
|
-
const
|
|
439
|
+
const decoratorIdentifier = (decorator) => {
|
|
440
|
+
const expression = decorator?.expression
|
|
441
|
+
if (!expression) return null
|
|
442
|
+
if (expression.type === "Identifier") return expression.name
|
|
443
|
+
if (expression.type === "CallExpression" && expression.callee.type === "Identifier") return expression.callee.name
|
|
444
|
+
return null
|
|
445
|
+
}
|
|
417
446
|
|
|
418
|
-
/**
|
|
419
|
-
|
|
420
|
-
*
|
|
421
|
-
* `no-double-cast`'s test-lane exemption already lives in `isTestFile`, consulted once, so every
|
|
422
|
-
* place the exit is in force is derivable from that one function. A per-line `eslint-disable` for
|
|
423
|
-
* any rule this law publishes is therefore never the lane speaking -- it is either redundant (the
|
|
424
|
-
* lane already covers the file) or an undeclared second exit (the file needed one and reached for
|
|
425
|
-
* a comment instead of a lane). Either way `type-safety.md` names this exact failure: "a per-line
|
|
426
|
-
* suppression standing in for a lane-wide exit".
|
|
427
|
-
*
|
|
428
|
-
* Deliberately scoped to only the five rule names this law publishes -- a rule that policed every
|
|
429
|
-
* `eslint-disable` in the repository would be reaching into every other family's lane, which is
|
|
430
|
-
* outside this file's boundary.
|
|
431
|
-
*/
|
|
432
|
-
export const noLineSuppression = {
|
|
447
|
+
/** A method that a caller or the framework reaches states what it returns. */
|
|
448
|
+
export const explicitHandlerReturnType = {
|
|
433
449
|
meta: {
|
|
434
450
|
type: "problem",
|
|
435
|
-
docs: {
|
|
436
|
-
description:
|
|
437
|
-
"No inline `eslint-disable` for a type-safety rule; the lane is the only sanctioned exit (TYPE-6, law 7).",
|
|
438
|
-
},
|
|
451
|
+
docs: { description: "Handlers and public methods of an Injectable, Resolver or Controller declare a return type." },
|
|
439
452
|
schema: [],
|
|
440
453
|
messages: {
|
|
441
|
-
|
|
442
|
-
"This
|
|
454
|
+
handler:
|
|
455
|
+
"This handler declares no return type, so the transport contract is whatever the body happens to return today and a change of the body silently changes the wire. Declare the return type (`Promise<Type>`).",
|
|
456
|
+
publicMethod:
|
|
457
|
+
"This public method declares no return type, so its callers depend on an inferred shape that changes with the body. Declare the return type.",
|
|
443
458
|
},
|
|
444
459
|
},
|
|
445
460
|
create(context) {
|
|
446
|
-
|
|
461
|
+
if (isTestFile(context.filename || context.getFilename())) return {}
|
|
447
462
|
return {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
463
|
+
MethodDefinition(node) {
|
|
464
|
+
if (node.kind !== "method" || node.static || !node.value.body || node.value.returnType) return
|
|
465
|
+
if (node.computed || node.key.type !== "Identifier") return
|
|
466
|
+
const isHandler = (node.decorators ?? []).some((decorator) => HANDLER_DECORATORS.has(decoratorIdentifier(decorator)))
|
|
467
|
+
if (isHandler) {
|
|
468
|
+
context.report({ node: node.key, messageId: "handler" })
|
|
469
|
+
return
|
|
455
470
|
}
|
|
471
|
+
if (node.accessibility === "private" || node.accessibility === "protected" || node.override) return
|
|
472
|
+
const klass = node.parent?.parent
|
|
473
|
+
if (!klass || klass.type !== "ClassDeclaration") return
|
|
474
|
+
const exported = klass.parent?.type === "ExportNamedDeclaration" || klass.parent?.type === "ExportDefaultDeclaration"
|
|
475
|
+
const decorators = [...(klass.decorators ?? []), ...(klass.parent?.decorators ?? [])].map(decoratorIdentifier)
|
|
476
|
+
if (!exported || !decorators.some((name) => SURFACE_CLASS_DECORATORS.has(name))) return
|
|
477
|
+
context.report({ node: node.key, messageId: "publicMethod" })
|
|
456
478
|
},
|
|
457
479
|
}
|
|
458
480
|
},
|
|
459
481
|
}
|
|
460
482
|
|
|
483
|
+
// -- TYPE-5 (rule 6) -- ATTEMPTED, NOT SHIPPED ------------------------------------------------------
|
|
484
|
+
//
|
|
485
|
+
// A rule was written here: two-or-more `is`/`has`-prefixed booleans sharing a type literal with a
|
|
486
|
+
// field that is optional and not itself boolean. It was measured against the reference backend
|
|
487
|
+
// before shipping, per this canon's own standard -- and both of its two real firings were wrong.
|
|
488
|
+
// `GlobalSearchItem` (`isEnrolled` / `isFree` / `isPremium` beside an unrelated optional
|
|
489
|
+
// `parentPath`) and `AdvertisementSeedItem` (`isHouseAd` / `isActive` beside an unrelated optional
|
|
490
|
+
// `ctaText`) are both genuinely independent facts about one entity, not a product of states -- the
|
|
491
|
+
// exact "transport type" and "independent booleans" exceptions `type-safety.md` already names. A
|
|
492
|
+
// rule reporting a defect on 2 of its 2 real firings is not a rule with debt to burn down; it is
|
|
493
|
+
// wrong, and shipping it at any level would teach a reader to distrust the machine rather than the
|
|
494
|
+
// code. TYPE-5 stays `documented`, exactly as the law's own `Layer held` table already says: seeing
|
|
495
|
+
// past a transport DTO to whether its flags are truly one situation needs the meaning of the code,
|
|
496
|
+
// which is a reader's job, not a decidable question of syntax.
|
|
497
|
+
|
|
461
498
|
/** The rules this law contributes to the plugin. */
|
|
462
499
|
export const rules = {
|
|
463
500
|
"no-double-cast": noDoubleCast,
|
|
@@ -465,7 +502,9 @@ export const rules = {
|
|
|
465
502
|
"no-inline-object-type": noInlineObjectType,
|
|
466
503
|
"no-const-enum": noConstEnum,
|
|
467
504
|
"no-unguarded-unknown-cast": noUnguardedUnknownCast,
|
|
468
|
-
"no-
|
|
505
|
+
"no-never-cast": noNeverCast,
|
|
506
|
+
"no-non-null-assertion": noNonNullAssertion,
|
|
507
|
+
"explicit-handler-return-type": explicitHandlerReturnType,
|
|
469
508
|
}
|
|
470
509
|
|
|
471
510
|
/**
|
|
@@ -501,7 +540,9 @@ export const recommended = {
|
|
|
501
540
|
"starci-be/no-inline-object-type": "error",
|
|
502
541
|
"starci-be/no-const-enum": "error",
|
|
503
542
|
"starci-be/no-unguarded-unknown-cast": "warn", // no=3 -- see type-safety.mjs's doc comment
|
|
504
|
-
"starci-be/no-
|
|
543
|
+
"starci-be/no-never-cast": "error",
|
|
544
|
+
"starci-be/no-non-null-assertion": "error",
|
|
545
|
+
"starci-be/explicit-handler-return-type": "error",
|
|
505
546
|
"@typescript-eslint/no-explicit-any": "error",
|
|
506
547
|
"@typescript-eslint/array-type": ["error", {
|
|
507
548
|
default: "generic",
|