@starci/eslint-canon-be 1.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/authorization.mjs +106 -0
- package/cdc.mjs +44 -0
- package/comments.mjs +243 -0
- package/cqrs.mjs +207 -0
- package/data-access.mjs +173 -0
- package/e2e-flow.mjs +213 -0
- package/event-delivery.mjs +40 -0
- package/exception-identity.mjs +200 -0
- package/exceptions.mjs +265 -0
- package/index.mjs +90 -0
- package/module-layering.mjs +171 -0
- package/naming.mjs +143 -0
- package/observability.mjs +141 -0
- package/package.json +35 -0
- package/testing.mjs +353 -0
- package/transport.mjs +103 -0
- package/type-safety.mjs +146 -0
package/exceptions.mjs
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `exceptions.md`.
|
|
3
|
+
*
|
|
4
|
+
* FOUR RULES, AND THE PAIRING IS THE POINT. Two watch the throw site and two watch the declaration,
|
|
5
|
+
* because either half alone has a hole the other closes: a class that extends a framework base is
|
|
6
|
+
* thrown by its own house-shaped name, so a throw-site rule passes it, and one such class stayed
|
|
7
|
+
* live and thrown from four call sites while the gate stayed green.
|
|
8
|
+
*
|
|
9
|
+
* `throw-abstract-exception` is a heuristic and says so: a rule reading one file at a time cannot
|
|
10
|
+
* verify that a thrown class extends anything, so it matches `Error` and a named list of framework
|
|
11
|
+
* exceptions. `exception-extends-abstract` is what makes that heuristic sound - it guarantees the
|
|
12
|
+
* only `*Exception` classes in the tree are house ones.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
16
|
+
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
17
|
+
|
|
18
|
+
/** The house base every exception extends. */
|
|
19
|
+
const EXCEPTION_BASE = "AbstractException"
|
|
20
|
+
|
|
21
|
+
/** Anything named like an exception. */
|
|
22
|
+
const EXCEPTION_NAME = /Exception$/
|
|
23
|
+
|
|
24
|
+
/** Framework exceptions that carry an HTTP status and no identity. */
|
|
25
|
+
const FRAMEWORK_EXCEPTIONS = new Set([
|
|
26
|
+
"BadRequestException",
|
|
27
|
+
"NotFoundException",
|
|
28
|
+
"UnauthorizedException",
|
|
29
|
+
"ForbiddenException",
|
|
30
|
+
"InternalServerErrorException",
|
|
31
|
+
"HttpException",
|
|
32
|
+
"ConflictException",
|
|
33
|
+
"NotAcceptableException",
|
|
34
|
+
"RequestTimeoutException",
|
|
35
|
+
"GoneException",
|
|
36
|
+
"PayloadTooLargeException",
|
|
37
|
+
"UnsupportedMediaTypeException",
|
|
38
|
+
"UnprocessableEntityException",
|
|
39
|
+
"NotImplementedException",
|
|
40
|
+
"BadGatewayException",
|
|
41
|
+
"ServiceUnavailableException",
|
|
42
|
+
"GatewayTimeoutException",
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
/** Where every exception is declared. */
|
|
46
|
+
const ERRORS_FOLDER = /\/exceptions\/errors\//
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* True when a file sits in an exceptions folder - ANY of them.
|
|
50
|
+
*
|
|
51
|
+
* THE PATH USED TO BE `/platform/exceptions/errors/`, WHICH WAS ONE REPOSITORY'S LAYOUT WRITTEN
|
|
52
|
+
* INTO A LAW. EXCEPTION-4 asks for one place per APPLICATION to look, not for one literal path: a
|
|
53
|
+
* repository with several apps has `apps/<name>/src/exceptions/errors/` per app and still satisfies
|
|
54
|
+
* it, because "what can this application throw" still has a single answer.
|
|
55
|
+
*
|
|
56
|
+
* Measured before changing: the narrow path reported 83 findings in a second back end, and the top
|
|
57
|
+
* offenders were files already sitting in an `exceptions/errors/` folder - just not that one. A rule
|
|
58
|
+
* that fires on correct code is worse than no rule, because the next author learns to scroll past it.
|
|
59
|
+
*/
|
|
60
|
+
const isInErrorsFolder = (filename) => ERRORS_FOLDER.test(normalizePath(filename))
|
|
61
|
+
|
|
62
|
+
/** The base class's own file, which is the one class allowed to extend something else. */
|
|
63
|
+
const isExceptionBaseFile = (filename) => /exceptions\/errors\/abstract\.ts$/.test(normalizePath(filename))
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The test lanes, where `throw new Error` is a test-runner assertion rather than a domain failure.
|
|
67
|
+
*
|
|
68
|
+
* THE CARVE-OUT WAS ALREADY GRANTED IN PROSE AND MISSING FROM THE RULE, which is the worst of the
|
|
69
|
+
* two places for them to disagree: canon said the lanes may throw, the artifact said they may not,
|
|
70
|
+
* and a repository adopting the rule inherited 69 findings its own canon had already excused.
|
|
71
|
+
*
|
|
72
|
+
* A flow that could not fail its own setup would have to invent a domain exception for "the fixture
|
|
73
|
+
* is missing", which is a failure of the test rather than of the business - and naming it as a
|
|
74
|
+
* domain failure would put it in the same vocabulary the product uses to describe real ones.
|
|
75
|
+
*/
|
|
76
|
+
const isTestLane = (filename) => {
|
|
77
|
+
const path = normalizePath(filename)
|
|
78
|
+
return /\.spec\.ts$/.test(path) || /-spec\.ts$/.test(path) || path.includes("/src/tests/")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A liveness or readiness probe, where the HTTP STATUS is the whole contract.
|
|
83
|
+
*
|
|
84
|
+
* THE RULE'S OWN REASON IS WHAT GRANTS THIS. A framework exception is refused because it "carries an
|
|
85
|
+
* HTTP status and no identity" - the status is not enough to tell two failures apart. A probe is the
|
|
86
|
+
* one endpoint where that sentence inverts: kubernetes reads the status code and NOTHING else, never
|
|
87
|
+
* the body, so the status is the entire identity and a domain exception carrying a rich code would
|
|
88
|
+
* arrive as a 500 and be read as "down" for the wrong reason.
|
|
89
|
+
*
|
|
90
|
+
* Narrow on purpose. Only a health controller qualifies, so this cannot become the place a service
|
|
91
|
+
* goes to avoid naming its failures.
|
|
92
|
+
*/
|
|
93
|
+
const isHealthProbe = (filename) => /\/health(?:z)?\.controller\.ts$|\/health\//.test(normalizePath(filename))
|
|
94
|
+
|
|
95
|
+
// -- EXCEPTION-1 -----------------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/** Throw a house exception, never `Error` and never a framework built-in. */
|
|
98
|
+
export const throwAbstractException = {
|
|
99
|
+
meta: {
|
|
100
|
+
type: "problem",
|
|
101
|
+
docs: { description: "Throw an `AbstractException` subclass, not `Error` or a framework exception." },
|
|
102
|
+
schema: [],
|
|
103
|
+
messages: {
|
|
104
|
+
bareError:
|
|
105
|
+
"`throw new Error(...)` carries a sentence and no code, so nothing downstream can group, match or retry on it without parsing English. Throw the exception class that NAMES this failure.",
|
|
106
|
+
framework:
|
|
107
|
+
"`throw new {{name}}(...)` carries an HTTP status and no identity - two unrelated failures arrive at a client indistinguishable, and the only thing telling them apart is the message, which is the part that gets reworded. Throw an `AbstractException` subclass.",
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
create(context) {
|
|
111
|
+
const filename = context.filename || context.getFilename()
|
|
112
|
+
// the lanes canon already excuses - see `isTestLane`
|
|
113
|
+
if (isTestLane(filename)) return {}
|
|
114
|
+
const probe = isHealthProbe(filename)
|
|
115
|
+
return {
|
|
116
|
+
ThrowStatement(node) {
|
|
117
|
+
const thrown = node.argument
|
|
118
|
+
if (!thrown || thrown.type !== "NewExpression" || thrown.callee.type !== "Identifier") return
|
|
119
|
+
const name = thrown.callee.name
|
|
120
|
+
if (name === "Error") {
|
|
121
|
+
context.report({ node: thrown, messageId: "bareError" })
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
// a probe answers with a status code and nothing else - see `isHealthProbe`. `Error` above
|
|
125
|
+
// is still refused here: a probe's status is its contract, an unnamed crash is not.
|
|
126
|
+
if (probe) return
|
|
127
|
+
if (FRAMEWORK_EXCEPTIONS.has(name)) {
|
|
128
|
+
context.report({ node: thrown, messageId: "framework", data: { name } })
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// -- EXCEPTION-2 -----------------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
/** The constructor takes one metadata object, `{}` when there is nothing to say. */
|
|
138
|
+
export const requireExceptionObjectArg = {
|
|
139
|
+
meta: {
|
|
140
|
+
type: "problem",
|
|
141
|
+
docs: { description: "`new XException(...)` passes exactly one object-literal argument." },
|
|
142
|
+
schema: [],
|
|
143
|
+
messages: {
|
|
144
|
+
zero:
|
|
145
|
+
"`new {{name}}()` - pass the metadata object even when it is empty: `new {{name}}({})`. One spelling for every throw means a reader never has to check whether THIS exception happens to take arguments.",
|
|
146
|
+
notObject:
|
|
147
|
+
"`new {{name}}(...)` takes a metadata OBJECT, not a positional value. A positional shape cannot grow: the day this failure needs a second field, every throw site is edited and the ones edited wrong still compile.",
|
|
148
|
+
extra:
|
|
149
|
+
"`new {{name}}(...)` takes exactly ONE metadata object, not several arguments.",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
create(context) {
|
|
153
|
+
return {
|
|
154
|
+
ThrowStatement(node) {
|
|
155
|
+
const thrown = node.argument
|
|
156
|
+
if (!thrown || thrown.type !== "NewExpression" || thrown.callee.type !== "Identifier") return
|
|
157
|
+
const name = thrown.callee.name
|
|
158
|
+
if (name === EXCEPTION_BASE || !EXCEPTION_NAME.test(name)) return
|
|
159
|
+
/*
|
|
160
|
+
* A FRAMEWORK EXCEPTION IS NOT A HOUSE ONE, AND ITS SHAPE IS NOT OURS TO DICTATE.
|
|
161
|
+
*
|
|
162
|
+
* This rule says a house exception takes exactly one metadata object. `EXCEPTION_NAME`
|
|
163
|
+
* matches anything ending in `Exception`, so it was also demanding that shape from Nest's
|
|
164
|
+
* own classes - `new ServiceUnavailableException(body)` is the constructor Nest published,
|
|
165
|
+
* and rewriting it to satisfy a house convention would change what the framework sends.
|
|
166
|
+
*
|
|
167
|
+
* Whether a framework exception may be thrown at all is EXCEPTION-1's question, and it
|
|
168
|
+
* answers it. This rule only governs the ones we declare.
|
|
169
|
+
*/
|
|
170
|
+
if (FRAMEWORK_EXCEPTIONS.has(name)) return
|
|
171
|
+
|
|
172
|
+
if (thrown.arguments.length === 0) {
|
|
173
|
+
context.report({ node: thrown, messageId: "zero", data: { name } })
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
if (thrown.arguments.length > 1) {
|
|
177
|
+
context.report({ node: thrown, messageId: "extra", data: { name } })
|
|
178
|
+
}
|
|
179
|
+
if (thrown.arguments[0].type !== "ObjectExpression") {
|
|
180
|
+
context.report({ node: thrown.arguments[0], messageId: "notObject", data: { name } })
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// -- EXCEPTION-3 -----------------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
/** The class extends the house base, so the throw site tells the truth about what is thrown. */
|
|
190
|
+
export const exceptionExtendsAbstract = {
|
|
191
|
+
meta: {
|
|
192
|
+
type: "problem",
|
|
193
|
+
docs: { description: "An `*Exception` class extends `AbstractException`." },
|
|
194
|
+
schema: [],
|
|
195
|
+
messages: {
|
|
196
|
+
base:
|
|
197
|
+
"`{{name}}` extends `{{parent}}`. Every exception extends `{{expected}}` - a framework base slips past the throw-site rule, because the throw then READS as a house exception and nothing looks wrong at any call site.",
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
create(context) {
|
|
201
|
+
if (isExceptionBaseFile(context.filename || context.getFilename())) return {}
|
|
202
|
+
return {
|
|
203
|
+
ClassDeclaration(node) {
|
|
204
|
+
if (!node.id || !EXCEPTION_NAME.test(node.id.name)) return
|
|
205
|
+
const parent = node.superClass
|
|
206
|
+
if (!parent || parent.type !== "Identifier" || parent.name === EXCEPTION_BASE) return
|
|
207
|
+
context.report({
|
|
208
|
+
node: parent,
|
|
209
|
+
messageId: "base",
|
|
210
|
+
data: { name: node.id.name, parent: parent.name, expected: EXCEPTION_BASE },
|
|
211
|
+
})
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// -- EXCEPTION-4 -----------------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
/** Every exception is declared in one folder, so the set of failures is readable in one place. */
|
|
220
|
+
export const exceptionInErrorsFolder = {
|
|
221
|
+
meta: {
|
|
222
|
+
type: "problem",
|
|
223
|
+
docs: { description: "An `*Exception` class is declared under the exceptions folder." },
|
|
224
|
+
schema: [],
|
|
225
|
+
messages: {
|
|
226
|
+
place:
|
|
227
|
+
"`{{name}}` is declared outside the exceptions folder. They all live in one place so \"what can this application throw?\" has one answer, and so a reviewer sees a new failure mode ARRIVE in a diff rather than discovering it in production.",
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
create(context) {
|
|
231
|
+
if (isInErrorsFolder(context.filename || context.getFilename())) return {}
|
|
232
|
+
return {
|
|
233
|
+
ClassDeclaration(node) {
|
|
234
|
+
if (!node.id || !EXCEPTION_NAME.test(node.id.name)) return
|
|
235
|
+
// an undecorated class with no base is a shape, not an exception - the declaration this
|
|
236
|
+
// rule is after always extends something
|
|
237
|
+
if (!node.superClass) return
|
|
238
|
+
context.report({ node: node.id, messageId: "place", data: { name: node.id.name } })
|
|
239
|
+
},
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The rules this law contributes to the plugin. */
|
|
245
|
+
export const rules = {
|
|
246
|
+
"throw-abstract-exception": throwAbstractException,
|
|
247
|
+
"require-exception-object-arg": requireExceptionObjectArg,
|
|
248
|
+
"exception-extends-abstract": exceptionExtendsAbstract,
|
|
249
|
+
"exception-in-errors-folder": exceptionInErrorsFolder,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
254
|
+
*
|
|
255
|
+
* All four measured at zero debt in the reference repository. The sanctioned exit is stated where it
|
|
256
|
+
* applies rather than carved into the rule: the spec family and the test tree may `throw new Error`,
|
|
257
|
+
* because there it means "the runner cannot continue" rather than naming a failure the product can
|
|
258
|
+
* produce. A consuming repository turns `throw-abstract-exception` off for those globs.
|
|
259
|
+
*/
|
|
260
|
+
export const recommended = {
|
|
261
|
+
"starci-be/throw-abstract-exception": "error",
|
|
262
|
+
"starci-be/require-exception-object-arg": "error",
|
|
263
|
+
"starci-be/exception-extends-abstract": "error",
|
|
264
|
+
"starci-be/exception-in-errors-folder": "error",
|
|
265
|
+
}
|
package/index.mjs
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The back-end plugin: every law's rules, gathered once.
|
|
3
|
+
*
|
|
4
|
+
* A consuming repository imports THIS and nothing below it. Registering rules one by one in the
|
|
5
|
+
* target's own plugin file is how the two lists drift: a law gains a rule here, nobody adds it there,
|
|
6
|
+
* and the rule ships as a document. That is not hypothetical for this axis - the back end kept a
|
|
7
|
+
* hand-written plugin whose names had already diverged from canon's, so `no-nest-logger` and
|
|
8
|
+
* `no-framework-logger` were one law wearing two names and no build could say which had fired.
|
|
9
|
+
*
|
|
10
|
+
* The target's `eslint.config.mjs` stays the authority on what is switched on, at what level and
|
|
11
|
+
* over which globs. That is a different question from what EXISTS, and this file answers only the
|
|
12
|
+
* second.
|
|
13
|
+
*
|
|
14
|
+
* WHAT THIS FILE REFUSES TO DO. It does not rename anything. A rule's published name is part of the
|
|
15
|
+
* law that declares it, because that name is what appears in a build log, in a disable comment and in
|
|
16
|
+
* every conversation about the failure. Aliasing one here to match a target's older spelling would
|
|
17
|
+
* leave two names for one rule and no way to tell which a message came from.
|
|
18
|
+
*/
|
|
19
|
+
import { recommended as authorizationRecommended, rules as authorizationRules } from "./authorization.mjs"
|
|
20
|
+
import { recommended as cdcRecommended, rules as cdcRules } from "./cdc.mjs"
|
|
21
|
+
import { recommended as commentsRecommended, rules as commentsRules } from "./comments.mjs"
|
|
22
|
+
import { recommended as cqrsRecommended, rules as cqrsRules } from "./cqrs.mjs"
|
|
23
|
+
import { recommended as dataAccessRecommended, rules as dataAccessRules } from "./data-access.mjs"
|
|
24
|
+
import { recommended as e2eFlowRecommended, rules as e2eFlowRules } from "./e2e-flow.mjs"
|
|
25
|
+
import { recommended as eventDeliveryRecommended, rules as eventDeliveryRules } from "./event-delivery.mjs"
|
|
26
|
+
import { recommended as exceptionIdentityRecommended, rules as exceptionIdentityRules } from "./exception-identity.mjs"
|
|
27
|
+
import { recommended as exceptionsRecommended, rules as exceptionsRules } from "./exceptions.mjs"
|
|
28
|
+
import { recommended as moduleLayeringRecommended, rules as moduleLayeringRules } from "./module-layering.mjs"
|
|
29
|
+
import { recommended as namingRecommended, rules as namingRules } from "./naming.mjs"
|
|
30
|
+
import { recommended as observabilityRecommended, rules as observabilityRules } from "./observability.mjs"
|
|
31
|
+
import { recommended as testingRecommended, rules as testingRules } from "./testing.mjs"
|
|
32
|
+
import { recommended as transportRecommended, rules as transportRules } from "./transport.mjs"
|
|
33
|
+
import { recommended as typeSafetyRecommended, rules as typeSafetyRules } from "./type-safety.mjs"
|
|
34
|
+
|
|
35
|
+
/** Each law's contribution, kept separate so a duplicate name is detectable rather than silent. */
|
|
36
|
+
const CONTRIBUTIONS = [
|
|
37
|
+
{ law: "authorization", rules: authorizationRules, recommended: authorizationRecommended },
|
|
38
|
+
{ law: "cdc", rules: cdcRules, recommended: cdcRecommended },
|
|
39
|
+
{ law: "comments", rules: commentsRules, recommended: commentsRecommended },
|
|
40
|
+
{ law: "cqrs", rules: cqrsRules, recommended: cqrsRecommended },
|
|
41
|
+
{ law: "data-access", rules: dataAccessRules, recommended: dataAccessRecommended },
|
|
42
|
+
{ law: "e2e-flow", rules: e2eFlowRules, recommended: e2eFlowRecommended },
|
|
43
|
+
{ law: "event-delivery", rules: eventDeliveryRules, recommended: eventDeliveryRecommended },
|
|
44
|
+
{ law: "exception-identity", rules: exceptionIdentityRules, recommended: exceptionIdentityRecommended },
|
|
45
|
+
{ law: "exceptions", rules: exceptionsRules, recommended: exceptionsRecommended },
|
|
46
|
+
{ law: "module-layering", rules: moduleLayeringRules, recommended: moduleLayeringRecommended },
|
|
47
|
+
{ law: "naming", rules: namingRules, recommended: namingRecommended },
|
|
48
|
+
{ law: "observability", rules: observabilityRules, recommended: observabilityRecommended },
|
|
49
|
+
{ law: "testing", rules: testingRules, recommended: testingRecommended },
|
|
50
|
+
{ law: "transport", rules: transportRules, recommended: transportRecommended },
|
|
51
|
+
{ law: "type-safety", rules: typeSafetyRules, recommended: typeSafetyRecommended },
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
/** Every gathered law. */
|
|
55
|
+
export const lawOwners = CONTRIBUTIONS.map((entry) => entry.law)
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Which law declares each rule.
|
|
59
|
+
*
|
|
60
|
+
* 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 is also what the twin test walks to prove no two
|
|
62
|
+
* laws claim one name.
|
|
63
|
+
*/
|
|
64
|
+
export const ruleOwners = Object.fromEntries(
|
|
65
|
+
CONTRIBUTIONS.flatMap((entry) => Object.keys(entry.rules).map((name) => [name, entry.law])),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
/** Every rule this canon publishes, keyed by its published name. */
|
|
69
|
+
export const rules = Object.fromEntries(
|
|
70
|
+
CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.rules)),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The levels this canon asks for, as the plugin's own opinion.
|
|
75
|
+
*
|
|
76
|
+
* Unlike the front end, this axis allows `warn`, and the allowance is the burn-down playbook rather
|
|
77
|
+
* than a softer standard: a rule measured with debt arrives at `warn` with a ledger entry naming the
|
|
78
|
+
* offenders, and flips to `error` the day that entry closes. Switching a rule on over existing
|
|
79
|
+
* offenders is what teaches an author to scroll past it, and a reader who has learned to scroll past
|
|
80
|
+
* one rule reads none of them.
|
|
81
|
+
*/
|
|
82
|
+
export const recommended = Object.fromEntries(
|
|
83
|
+
CONTRIBUTIONS.flatMap((entry) => Object.entries(entry.recommended)),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
/** The plugin object, shaped the way a flat config expects it. */
|
|
87
|
+
export default {
|
|
88
|
+
meta: { name: "eslint-plugin-starci-be" },
|
|
89
|
+
rules,
|
|
90
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `module-layering.mjs`'s law file, `module-layering.md`.
|
|
3
|
+
*
|
|
4
|
+
* Two rules, both reading import specifiers against the path of the file doing the importing. They
|
|
5
|
+
* are cheap and exact, which is the whole reason they can be `error`.
|
|
6
|
+
*
|
|
7
|
+
* LAYERING-3 (a capability module importing a sibling capability) is NOT here, and the omission is
|
|
8
|
+
* deliberate rather than an oversight: deciding whether an imported module is a sibling capability
|
|
9
|
+
* or a nested child needs the module graph, and a rule reading one file at a time cannot see it. In
|
|
10
|
+
* the reference repository that rule exists and is scoped by path glob; a repository adopting this
|
|
11
|
+
* law should port it as a gate that walks the tree, not as a per-file rule that guesses.
|
|
12
|
+
*
|
|
13
|
+
* THE META-ROOT IS THE TRAP IN BOTH RULES. Some capabilities sit behind a category folder
|
|
14
|
+
* (`platform/`, `lib/`, `integrations/`), so the capability name is the SECOND segment there and
|
|
15
|
+
* the first everywhere else. Get that wrong and `@modules/platform/exceptions` reads as a
|
|
16
|
+
* capability-with-file rather than as a barrel.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
20
|
+
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Category folders that hold capabilities rather than being one.
|
|
24
|
+
*
|
|
25
|
+
* Under these the capability name is the second segment: `@modules/platform/exceptions` names a
|
|
26
|
+
* capability and no file, so it is a barrel -- while `@modules/ai` names a capability directly.
|
|
27
|
+
*/
|
|
28
|
+
const META_ROOTS = new Set(["platform", "lib", "integrations"])
|
|
29
|
+
|
|
30
|
+
/** The aliases a capability is reachable through. */
|
|
31
|
+
const ALIASES = [
|
|
32
|
+
{
|
|
33
|
+
prefix: "@modules/",
|
|
34
|
+
root: "/src/modules/",
|
|
35
|
+
metaAware: true,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
prefix: "@features/",
|
|
39
|
+
root: "/src/features/",
|
|
40
|
+
metaAware: false,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
prefix: "@tests/",
|
|
44
|
+
root: "/src/tests/",
|
|
45
|
+
metaAware: false,
|
|
46
|
+
},
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
// -- LAYERING-1 ------------------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** An import names the declaring file, never a folder that re-exports one. */
|
|
52
|
+
export const mustDeepModuleImport = {
|
|
53
|
+
meta: {
|
|
54
|
+
type: "problem",
|
|
55
|
+
docs: { description: "Import the declaring file, never a capability barrel." },
|
|
56
|
+
schema: [],
|
|
57
|
+
messages: {
|
|
58
|
+
barrel:
|
|
59
|
+
"`{{specifier}}` names a capability and no file. A barrel pulls the whole folder's import graph in to get one symbol - which is how a unit spec ends up booting a database driver, and how two capabilities that never reference each other end up in a cycle through a third. Name the file that declares the symbol.",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
create(context) {
|
|
63
|
+
const check = (node, specifier) => {
|
|
64
|
+
if (typeof specifier !== "string") return
|
|
65
|
+
const alias = ALIASES.find((candidate) => specifier.startsWith(candidate.prefix))
|
|
66
|
+
if (!alias) return
|
|
67
|
+
|
|
68
|
+
const rest = specifier.slice(alias.prefix.length)
|
|
69
|
+
if (!rest) {
|
|
70
|
+
context.report({ node, messageId: "barrel", data: { specifier } })
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
const parts = rest.split("/")
|
|
74
|
+
const barrelDepth = alias.metaAware && META_ROOTS.has(parts[0])
|
|
75
|
+
? 2
|
|
76
|
+
: 1
|
|
77
|
+
if (parts.length <= barrelDepth) {
|
|
78
|
+
context.report({ node, messageId: "barrel", data: { specifier } })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
ImportDeclaration(node) {
|
|
83
|
+
check(node.source, node.source.value)
|
|
84
|
+
},
|
|
85
|
+
ExportNamedDeclaration(node) {
|
|
86
|
+
if (node.source) check(node.source, node.source.value)
|
|
87
|
+
},
|
|
88
|
+
ExportAllDeclaration(node) {
|
|
89
|
+
if (node.source) check(node.source, node.source.value)
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// -- LAYERING-2 ------------------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/** The aliases a file's OWN capability is reachable through, or null when it is in none. */
|
|
98
|
+
const selfAliases = (filename) => {
|
|
99
|
+
const file = normalizePath(filename)
|
|
100
|
+
for (const alias of ALIASES) {
|
|
101
|
+
const at = file.lastIndexOf(alias.root)
|
|
102
|
+
if (at === -1) continue
|
|
103
|
+
const parts = file.slice(at + alias.root.length).split("/")
|
|
104
|
+
if (!parts[0]) continue
|
|
105
|
+
if (alias.metaAware && META_ROOTS.has(parts[0]) && parts.length >= 2) {
|
|
106
|
+
// reachable long (`platform/exceptions`) and short (`exceptions`), so both are self
|
|
107
|
+
return {
|
|
108
|
+
prefix: alias.prefix,
|
|
109
|
+
keys: [`${parts[0]}/${parts[1]}`, parts[1]],
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
prefix: alias.prefix,
|
|
114
|
+
keys: [parts[0]],
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Inside a capability, imports are relative -- never the capability's own public alias. */
|
|
121
|
+
export const noSelfModuleAlias = {
|
|
122
|
+
meta: {
|
|
123
|
+
type: "problem",
|
|
124
|
+
docs: { description: "Inside a capability, import relatively rather than through its own alias." },
|
|
125
|
+
schema: [],
|
|
126
|
+
messages: {
|
|
127
|
+
self:
|
|
128
|
+
"`{{specifier}}` reaches this capability through its own public alias. That is the capability talking to itself through its front door: a cycle magnet, and a lie about the boundary - the alias exists to say \"this comes from elsewhere\", so using it for something that does not is exactly the signal that stops meaning anything. Use a relative import.",
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
create(context) {
|
|
132
|
+
const self = selfAliases(context.filename || context.getFilename())
|
|
133
|
+
if (!self) return {}
|
|
134
|
+
|
|
135
|
+
const check = (node, specifier) => {
|
|
136
|
+
if (typeof specifier !== "string" || !specifier.startsWith(self.prefix)) return
|
|
137
|
+
const rest = specifier.slice(self.prefix.length)
|
|
138
|
+
const hit = self.keys.some((key) => rest === key || rest.startsWith(`${key}/`))
|
|
139
|
+
if (hit) context.report({ node, messageId: "self", data: { specifier } })
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
ImportDeclaration(node) {
|
|
143
|
+
check(node.source, node.source.value)
|
|
144
|
+
},
|
|
145
|
+
ExportNamedDeclaration(node) {
|
|
146
|
+
if (node.source) check(node.source, node.source.value)
|
|
147
|
+
},
|
|
148
|
+
ExportAllDeclaration(node) {
|
|
149
|
+
if (node.source) check(node.source, node.source.value)
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The rules this law contributes to the plugin. */
|
|
156
|
+
export const rules = {
|
|
157
|
+
"must-deep-module-import": mustDeepModuleImport,
|
|
158
|
+
"no-self-module-alias": noSelfModuleAlias,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
163
|
+
*
|
|
164
|
+
* Both measured at zero debt in the reference repository. A repository adopting them into an
|
|
165
|
+
* existing tree will not be at zero: barrels are the default in most codebases, so measure first
|
|
166
|
+
* and land above zero at `warn` with the count, exactly as the burn-down procedure says.
|
|
167
|
+
*/
|
|
168
|
+
export const recommended = {
|
|
169
|
+
"starci-be/must-deep-module-import": "error",
|
|
170
|
+
"starci-be/no-self-module-alias": "error",
|
|
171
|
+
}
|
package/naming.mjs
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold `naming.md`.
|
|
3
|
+
*
|
|
4
|
+
* Two rules, which are the only two parts of naming a parser can actually judge: whether a name
|
|
5
|
+
* encodes a schema VERSION, and whether an exported function is a bare verb.
|
|
6
|
+
*
|
|
7
|
+
* A THIRD RULE WAS WRITTEN AND DELETED, and the deletion is the useful part. It demanded that a
|
|
8
|
+
* file name spell out the class it declares, and measured 616 offenders in 4430 files -- because
|
|
9
|
+
* the convention is the opposite of what it assumed: the PATH carries the role and the scope
|
|
10
|
+
* (`parsers/content.service.ts` and `path/content.service.ts` declare `ContentParserService` and
|
|
11
|
+
* `ContentPathService`), and the file names only the subject. Fourteen percent of a tree is a
|
|
12
|
+
* convention, not debt. Canon records what the code does; a rule that disagrees with the code at
|
|
13
|
+
* that scale is the rule being wrong.
|
|
14
|
+
*
|
|
15
|
+
* The rest of the law is not enforced and should not be faked. Whether a name describes a folder
|
|
16
|
+
* (NAME-3), a mechanism (NAME-4) or its first caller (NAME-7) needs to know what the thing IS --
|
|
17
|
+
* `VolumeService` reads perfectly well until you know the folder was renamed, and no parser knows
|
|
18
|
+
* that. Those are read by a person, which is why the law states them with the scars attached.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
22
|
+
const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
|
|
23
|
+
|
|
24
|
+
/** A schema generation baked into an identifier. */
|
|
25
|
+
const VERSIONED_NAME = /(?:^|[a-z])V[0-9]+(?:$|[A-Z_])|_V[0-9]+/
|
|
26
|
+
|
|
27
|
+
/** Bare verbs that say nothing about their object. */
|
|
28
|
+
const BARE_VERBS = new Set([
|
|
29
|
+
"generate",
|
|
30
|
+
"parse",
|
|
31
|
+
"run",
|
|
32
|
+
"handle",
|
|
33
|
+
"process",
|
|
34
|
+
"build",
|
|
35
|
+
"create",
|
|
36
|
+
"load",
|
|
37
|
+
"resolve",
|
|
38
|
+
"check",
|
|
39
|
+
"convert",
|
|
40
|
+
"transform",
|
|
41
|
+
"send",
|
|
42
|
+
"fetch",
|
|
43
|
+
"get",
|
|
44
|
+
"set",
|
|
45
|
+
"update",
|
|
46
|
+
"apply",
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
// -- NAME-2 ----------------------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/** A name says what a thing is, never which schema generation it was built for. */
|
|
52
|
+
export const noVersionInName = {
|
|
53
|
+
meta: {
|
|
54
|
+
type: "problem",
|
|
55
|
+
docs: { description: "Identifiers do not encode a schema version." },
|
|
56
|
+
schema: [],
|
|
57
|
+
messages: {
|
|
58
|
+
versioned:
|
|
59
|
+
"`{{name}}` bakes a schema generation into the name. It has to be renamed the day the next one arrives, and until then nobody can tell from the name whether it means the CURRENT shape or an old one -- so every reader goes and finds out. Name the property that generation introduced instead: `hasVerifiedMarker`, not `isV2`.",
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
create(context) {
|
|
63
|
+
const report = (node, name) => {
|
|
64
|
+
if (!name || !VERSIONED_NAME.test(name)) return
|
|
65
|
+
context.report({ node, messageId: "versioned", data: { name } })
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
FunctionDeclaration(node) {
|
|
69
|
+
if (node.id) report(node.id, node.id.name)
|
|
70
|
+
},
|
|
71
|
+
ClassDeclaration(node) {
|
|
72
|
+
if (node.id) report(node.id, node.id.name)
|
|
73
|
+
},
|
|
74
|
+
TSInterfaceDeclaration(node) {
|
|
75
|
+
if (node.id) report(node.id, node.id.name)
|
|
76
|
+
},
|
|
77
|
+
TSTypeAliasDeclaration(node) {
|
|
78
|
+
if (node.id) report(node.id, node.id.name)
|
|
79
|
+
},
|
|
80
|
+
MethodDefinition(node) {
|
|
81
|
+
if (node.key && node.key.type === "Identifier") report(node.key, node.key.name)
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// -- NAME-5 ----------------------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/** An exported function is a verb plus its object. */
|
|
90
|
+
export const noBareVerbExport = {
|
|
91
|
+
meta: {
|
|
92
|
+
type: "problem",
|
|
93
|
+
docs: { description: "An exported function names its object, not just its verb." },
|
|
94
|
+
schema: [],
|
|
95
|
+
messages: {
|
|
96
|
+
bareVerb:
|
|
97
|
+
"`{{name}}` is a bare verb: {{name}} WHAT? At an import list it collides with every other module's `{{name}}`, so the reader falls back on reading the path to tell them apart -- and the path is the thing that moves. Give it its object.",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
create(context) {
|
|
101
|
+
const check = (node, name) => {
|
|
102
|
+
if (!name || !BARE_VERBS.has(name)) return
|
|
103
|
+
context.report({ node, messageId: "bareVerb", data: { name } })
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
ExportNamedDeclaration(node) {
|
|
107
|
+
const declaration = node.declaration
|
|
108
|
+
if (!declaration) return
|
|
109
|
+
if (declaration.type === "FunctionDeclaration" && declaration.id) {
|
|
110
|
+
check(declaration.id, declaration.id.name)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
if (declaration.type !== "VariableDeclaration") return
|
|
114
|
+
for (const one of declaration.declarations) {
|
|
115
|
+
const init = one.init
|
|
116
|
+
const isFunction = init
|
|
117
|
+
&& (init.type === "ArrowFunctionExpression" || init.type === "FunctionExpression")
|
|
118
|
+
if (isFunction && one.id && one.id.type === "Identifier") check(one.id, one.id.name)
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The rules this law contributes to the plugin. */
|
|
126
|
+
export const rules = {
|
|
127
|
+
"no-version-in-name": noVersionInName,
|
|
128
|
+
"no-bare-verb-export": noBareVerbExport,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The level this law asks for, as the plugin's own opinion.
|
|
133
|
+
*
|
|
134
|
+
* MEASURE THESE BEFORE SWITCHING THEM ON. Unlike the other backend laws, naming rules land on a
|
|
135
|
+
* mature tree with real debt: a repository that has been running for a while will have file names
|
|
136
|
+
* that drifted from their exports and methods carrying a schema generation. Land above zero at
|
|
137
|
+
* `warn` with the count and burn it down -- a naming rule at `error` on day one blocks every commit
|
|
138
|
+
* that touches an old file, which teaches people to disable it.
|
|
139
|
+
*/
|
|
140
|
+
export const recommended = {
|
|
141
|
+
"starci-be/no-version-in-name": "warn",
|
|
142
|
+
"starci-be/no-bare-verb-export": "warn",
|
|
143
|
+
}
|