@starci/eslint-canon-be 1.0.1 → 1.2.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 -69
- package/cdc.mjs +107 -1
- package/comments.mjs +147 -1
- package/cqrs.mjs +103 -7
- package/data-access.mjs +144 -10
- package/e2e-flow.mjs +222 -213
- package/event-delivery.mjs +54 -1
- package/index.mjs +19 -2
- package/module-layering.mjs +268 -7
- package/naming.mjs +80 -3
- package/observability.mjs +140 -6
- package/package.json +1 -1
- package/testing.mjs +147 -46
- package/transport.mjs +65 -0
- package/type-safety.mjs +242 -8
package/testing.mjs
CHANGED
|
@@ -10,6 +10,34 @@
|
|
|
10
10
|
* whose every assertion is about a call rather than a result, and an end-to-end spec that never
|
|
11
11
|
* reads any state back. Both were measured before being written: eight specs and one e2e file in
|
|
12
12
|
* the source repository, which is why the second lands with a carve-out rather than at zero.
|
|
13
|
+
*
|
|
14
|
+
* Two more codes turned out to have a genuinely mechanical HALF, added later and kept narrow on
|
|
15
|
+
* purpose:
|
|
16
|
+
*
|
|
17
|
+
* - TESTING-1. A filename cannot be checked against a business sentence - but the anti-pattern
|
|
18
|
+
* the law names by example (`rewards-queries.e2e-spec.ts`) IS a closed, denylisted shape: a
|
|
19
|
+
* filename whose last segment is an API noun rather than a business one. The rule refuses only
|
|
20
|
+
* that shape; it does not claim to certify a good name, and it is the same requirement E2E-1 in
|
|
21
|
+
* `e2e-flow.md` states on the same file, so one rule holds both codes rather than reporting the
|
|
22
|
+
* same file twice under two names.
|
|
23
|
+
* - TESTING-7. "A stub of a model returns a payload the production parser can actually parse" -
|
|
24
|
+
* the rule cannot verify the payload parses, but it CAN refuse the one shape the law names by
|
|
25
|
+
* example: a bare marker string (`"stubbed"`, `"ok"`, `"test"`) standing in for the whole
|
|
26
|
+
* answer. An object literal, or a `JSON.stringify(...)` call, is out of the rule's reach on
|
|
27
|
+
* purpose - that is where a false positive would start.
|
|
28
|
+
*
|
|
29
|
+
* TESTING-5 / TESTING-8 ("a configured lane either holds tests or does not exist") was drafted as a
|
|
30
|
+
* filesystem-reading rule and then WITHDRAWN. It reads correctly as JS to `@typescript-eslint/parser`
|
|
31
|
+
* (`{"rootDir": "..."}` parses as an `ExpressionStatement` over an `ObjectExpression` under this
|
|
32
|
+
* parser, no wrapping needed) and its own file-scoped RuleTester cases passed. It is left out anyway,
|
|
33
|
+
* because the question it would answer is not "does this repository's config have this shape" but
|
|
34
|
+
* "does the CONSUMING repository's `eslint.config.mjs` even point a linter at this file at all" - and
|
|
35
|
+
* in the measured reference repository it does not: `files` there is five TypeScript globs under
|
|
36
|
+
* `src`, `apps`, `libs`, `test` and `tests` - none of them `.json` - so `jest-e2e.json` and
|
|
37
|
+
* `jest-harness.json` sit outside every one of them. A rule that cannot be reached by the config
|
|
38
|
+
* that would run it is not a `warn`-with-a-count away from correct; it is dead code reporting a "0"
|
|
39
|
+
* that means "never asked", not "already true" - the one measurement this family refuses to publish
|
|
40
|
+
* as if it were the other.
|
|
13
41
|
*/
|
|
14
42
|
|
|
15
43
|
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
@@ -171,50 +199,6 @@ export const noModelCallInE2e = {
|
|
|
171
199
|
},
|
|
172
200
|
}
|
|
173
201
|
|
|
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
202
|
// -- TESTING-10 ------------------------------------------------------------------------------------
|
|
219
203
|
|
|
220
204
|
/** Official provider clients that a harness may call without a house routing layer. */
|
|
@@ -321,13 +305,118 @@ export const harnessCallsProviderDirectly = {
|
|
|
321
305
|
},
|
|
322
306
|
}
|
|
323
307
|
|
|
308
|
+
// -- TESTING-1 (and E2E-1 in e2e-flow.md, the same requirement on the same file) -------------------
|
|
309
|
+
|
|
310
|
+
/** Nouns that name an API shape rather than a business promise. `rewards-queries` is the anchor. */
|
|
311
|
+
const API_SHAPED_FILENAME_NOUNS = new Set([
|
|
312
|
+
"queries", "query", "mutations", "mutation",
|
|
313
|
+
"resolvers", "resolver", "endpoints", "endpoint",
|
|
314
|
+
"controllers", "controller", "handlers", "handler",
|
|
315
|
+
"modules", "module", "apis", "api",
|
|
316
|
+
])
|
|
317
|
+
|
|
318
|
+
/** The hyphen-separated words of an e2e filename, with the lane suffix stripped. */
|
|
319
|
+
const e2eFilenameSegments = (filename) => {
|
|
320
|
+
const base = normalizePath(filename).split("/").pop() || ""
|
|
321
|
+
return base.replace(/\.e2e-spec\.ts$/, "").split("-").filter(Boolean)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** An e2e file is one business flow; the filename may not be an API-shape noun wearing its clothes. */
|
|
325
|
+
export const noApiShapedE2eFilename = {
|
|
326
|
+
meta: {
|
|
327
|
+
type: "problem",
|
|
328
|
+
docs: {
|
|
329
|
+
description:
|
|
330
|
+
"An e2e filename reads as a business sentence (TESTING-1; E2E-1 in e2e-flow.md), not a resolver/endpoint/module group.",
|
|
331
|
+
},
|
|
332
|
+
schema: [],
|
|
333
|
+
messages: {
|
|
334
|
+
apiShaped:
|
|
335
|
+
"`{{segment}}` names an API shape - a resolver group, an endpoint, a module - not a business promise. TESTING-1 / E2E-1: one e2e file is one business flow and the filename IS that flow; `rewards-queries.e2e-spec.ts` is exactly the shape this refuses. Name the file for the sentence it proves (`course-purchase.e2e-spec.ts`), not the API surface it happens to hit.",
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
create(context) {
|
|
339
|
+
const filename = context.filename || context.getFilename()
|
|
340
|
+
if (!isE2eSpec(filename)) return {}
|
|
341
|
+
const segments = e2eFilenameSegments(filename)
|
|
342
|
+
if (segments.length === 0) return {}
|
|
343
|
+
const last = segments[segments.length - 1].toLowerCase()
|
|
344
|
+
if (!API_SHAPED_FILENAME_NOUNS.has(last)) return {}
|
|
345
|
+
return {
|
|
346
|
+
Program(node) {
|
|
347
|
+
context.report({ node, messageId: "apiShaped", data: { segment: last } })
|
|
348
|
+
},
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// -- TESTING-7 -------------------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
/** Test-infra helpers, where the shared model stub this code governs actually lives. */
|
|
356
|
+
const isTestHelperFile = (filename) => /\/tests\/helpers\//.test(normalizePath(filename))
|
|
357
|
+
|
|
358
|
+
/** Bare markers a stub returns when nobody gave it a real answer to stand in for. */
|
|
359
|
+
const MARKER_STRINGS = new Set(["stubbed", "stub", "ok", "test", "mock", "fake", "todo", "tbd", "n/a", "pending", ""])
|
|
360
|
+
|
|
361
|
+
/** A string literal that is nothing but a marker - not an object, not a `JSON.stringify(...)` call. */
|
|
362
|
+
const isMarkerLiteral = (node) => Boolean(
|
|
363
|
+
node && node.type === "Literal" && typeof node.value === "string" && MARKER_STRINGS.has(node.value.trim().toLowerCase()),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
/** The world's default model stub returns a payload the production parser can actually parse. */
|
|
367
|
+
export const noMarkerModelStub = {
|
|
368
|
+
meta: {
|
|
369
|
+
type: "problem",
|
|
370
|
+
docs: {
|
|
371
|
+
description: "A model stub in test infra returns a parseable payload (TESTING-7), never a bare marker string.",
|
|
372
|
+
},
|
|
373
|
+
schema: [],
|
|
374
|
+
messages: {
|
|
375
|
+
marker:
|
|
376
|
+
"This stub resolves to `{{value}}`, a marker rather than an answer. TESTING-7: a stub of a model returns a payload the production parser can actually parse - a marker string means the strict-JSON parser the real flow depends on never runs, so the most fragile seam in the flow is never exercised. Return the shape the production parser expects (an object, or a `JSON.stringify(...)` of one), the way `DEFAULT_MODEL_ANSWER` does.",
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
create(context) {
|
|
380
|
+
if (!isTestHelperFile(context.filename || context.getFilename())) return {}
|
|
381
|
+
return {
|
|
382
|
+
CallExpression(node) {
|
|
383
|
+
const callee = node.callee
|
|
384
|
+
if (!callee || callee.type !== "MemberExpression" || callee.computed) return
|
|
385
|
+
const method = callee.property.name
|
|
386
|
+
if (method === "mockResolvedValue" || method === "mockReturnValue") {
|
|
387
|
+
const argument = node.arguments[0]
|
|
388
|
+
if (isMarkerLiteral(argument)) {
|
|
389
|
+
context.report({ node: argument, messageId: "marker", data: { value: JSON.stringify(argument.value) } })
|
|
390
|
+
}
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
if (method !== "mockImplementation") return
|
|
394
|
+
const fn = node.arguments[0]
|
|
395
|
+
if (!fn || (fn.type !== "ArrowFunctionExpression" && fn.type !== "FunctionExpression")) return
|
|
396
|
+
if (fn.body.type !== "BlockStatement") {
|
|
397
|
+
if (isMarkerLiteral(fn.body)) {
|
|
398
|
+
context.report({ node: fn.body, messageId: "marker", data: { value: JSON.stringify(fn.body.value) } })
|
|
399
|
+
}
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
for (const statement of fn.body.body) {
|
|
403
|
+
if (statement.type === "ReturnStatement" && isMarkerLiteral(statement.argument)) {
|
|
404
|
+
context.report({ node: statement.argument, messageId: "marker", data: { value: JSON.stringify(statement.argument.value) } })
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
}
|
|
409
|
+
},
|
|
410
|
+
}
|
|
411
|
+
|
|
324
412
|
/** The rules this law contributes to the plugin. */
|
|
325
413
|
export const rules = {
|
|
326
414
|
"no-call-only-spec": noCallOnlySpec,
|
|
327
415
|
"e2e-asserts-persisted-state": e2eAssertsPersistedState,
|
|
328
416
|
"no-model-call-in-e2e": noModelCallInE2e,
|
|
329
|
-
"e2e-uses-production-transport": e2eUsesProductionTransport,
|
|
330
417
|
"harness-calls-provider-directly": harnessCallsProviderDirectly,
|
|
418
|
+
"no-api-shaped-e2e-filename": noApiShapedE2eFilename,
|
|
419
|
+
"no-marker-model-stub": noMarkerModelStub,
|
|
331
420
|
}
|
|
332
421
|
|
|
333
422
|
/**
|
|
@@ -343,11 +432,23 @@ export const rules = {
|
|
|
343
432
|
* config never loaded. Count only the reports carrying this rule's own id.
|
|
344
433
|
*
|
|
345
434
|
* Both land at `warn` with the count, get burned down, and flip to `error` at zero.
|
|
435
|
+
*
|
|
436
|
+
* The two later rules were measured the same way, against the same repository:
|
|
437
|
+
*
|
|
438
|
+
* - `no-api-shaped-e2e-filename` found TWO of 77 - `installment-plan-queries.e2e-spec.ts` and
|
|
439
|
+
* `rewards-queries.e2e-spec.ts`, the exact anchor example the law names. Real, pre-existing debt
|
|
440
|
+
* this task does not fix, so it lands at `warn` rather than claiming a burn-down that has not
|
|
441
|
+
* happened.
|
|
442
|
+
* - `no-marker-model-stub` found ZERO of the world's own stub in `tests/helpers/flow-world.ts`:
|
|
443
|
+
* the default answer is `JSON.stringify({ answer: "..." })`, which is a `CallExpression`, not a
|
|
444
|
+
* `Literal`, so the rule does not even see it as a candidate. It ships at `error` as a
|
|
445
|
+
* regression guard on a shape that is already correct.
|
|
346
446
|
*/
|
|
347
447
|
export const recommended = {
|
|
348
448
|
"starci-be/no-call-only-spec": "error", // no=0 of 182 - burned down from 1
|
|
349
449
|
"starci-be/e2e-asserts-persisted-state": "error", // no=0 of 47 - burned down from 1
|
|
350
450
|
"starci-be/no-model-call-in-e2e": "error", // no=0 of 47
|
|
351
|
-
"starci-be/e2e-uses-production-transport": "error",
|
|
352
451
|
"starci-be/harness-calls-provider-directly": "error",
|
|
452
|
+
"starci-be/no-api-shaped-e2e-filename": "warn", // no=2 of 77 - pre-existing, not burned down here
|
|
453
|
+
"starci-be/no-marker-model-stub": "error", // no=0 - the world's stub already returns JSON.stringify(...)
|
|
353
454
|
}
|
package/transport.mjs
CHANGED
|
@@ -92,12 +92,77 @@ export const doorLivesInFeatures = {
|
|
|
92
92
|
},
|
|
93
93
|
}
|
|
94
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
|
+
|
|
95
150
|
export const rules = {
|
|
96
151
|
"rest-door-needs-a-reason": restDoorNeedsAReason,
|
|
97
152
|
"door-lives-in-features": doorLivesInFeatures,
|
|
153
|
+
"no-capability-imports-features": noCapabilityImportsFeatures,
|
|
98
154
|
}
|
|
99
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
|
+
*/
|
|
100
164
|
export const recommended = {
|
|
101
165
|
"starci-be/rest-door-needs-a-reason": "error",
|
|
102
166
|
"starci-be/door-lives-in-features": "error",
|
|
167
|
+
"starci-be/no-capability-imports-features": "warn",
|
|
103
168
|
}
|
package/type-safety.mjs
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The rules that hold `type-safety.md`.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Five house rules plus two standard ones, and the split is worth knowing. `no-explicit-any` and
|
|
5
5
|
* the array-type spelling already exist in the TypeScript plugin and are named in `recommended`
|
|
6
6
|
* rather than reimplemented -- a second implementation of a rule everybody has is a maintenance
|
|
7
|
-
* cost with no gain.
|
|
7
|
+
* cost with no gain. Rule 1 (`any` in a declaration, cast target or generic argument) is exactly
|
|
8
|
+
* that rule; a house-written twin of `no-explicit-any` would be the two-homes-for-one-law failure
|
|
9
|
+
* this canon exists to prevent, so rule 1 is deliberately not reimplemented here.
|
|
8
10
|
*
|
|
9
|
-
* What is house-written is what the standard set does not cover: the double cast (TYPE-2),
|
|
10
|
-
* inline parameter type (TYPE-3)
|
|
11
|
-
*
|
|
11
|
+
* What is house-written is what the standard set does not cover: the double cast (TYPE-2, rule 3),
|
|
12
|
+
* the inline parameter type (TYPE-3, rule 4), the const enum (TYPE-4, rule 5), the unguarded cast
|
|
13
|
+
* off an `unknown` value (TYPE-1's second half, rule 2), and the per-line suppression standing in
|
|
14
|
+
* for a lane (TYPE-6, rule 7). Each is a way of switching the compiler off -- or of leaving it
|
|
15
|
+
* turned further down than the law asks -- that looks locally reasonable, which is exactly the
|
|
16
|
+
* class a rule is for.
|
|
12
17
|
*
|
|
13
|
-
* TYPE-5
|
|
14
|
-
*
|
|
15
|
-
*
|
|
18
|
+
* TYPE-5 (rule 6, the boolean-flag product) is NOT among them, and that absence was tested rather
|
|
19
|
+
* than assumed. See the comment where TYPE-5 would sit, below, for what was tried and why it was
|
|
20
|
+
* withdrawn after being measured against the reference backend.
|
|
16
21
|
*/
|
|
17
22
|
|
|
18
23
|
/** Forward-slash form of a filename, so Windows paths compare like every other path. */
|
|
@@ -55,6 +60,161 @@ export const noDoubleCast = {
|
|
|
55
60
|
},
|
|
56
61
|
}
|
|
57
62
|
|
|
63
|
+
// -- TYPE-1, second half (rule 2) -------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/** typeof/instanceof/in/null-equality/Array.isArray are the sanctioned ways to narrow an `unknown`. */
|
|
66
|
+
const isNullOrUndefinedLiteral = (node) =>
|
|
67
|
+
(node.type === "Literal" && node.value === null) || (node.type === "Identifier" && node.name === "undefined")
|
|
68
|
+
|
|
69
|
+
/** The nearest enclosing function, so narrowing found in one function never covers another. */
|
|
70
|
+
const enclosingFunction = (node) => {
|
|
71
|
+
let current = node.parent
|
|
72
|
+
while (current) {
|
|
73
|
+
if (
|
|
74
|
+
current.type === "FunctionDeclaration"
|
|
75
|
+
|| current.type === "FunctionExpression"
|
|
76
|
+
|| current.type === "ArrowFunctionExpression"
|
|
77
|
+
) {
|
|
78
|
+
return current
|
|
79
|
+
}
|
|
80
|
+
current = current.parent
|
|
81
|
+
}
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Whether a parameter or variable is explicitly declared `: unknown` at the point it enters. */
|
|
86
|
+
const isUnknownAnnotation = (typeAnnotation) =>
|
|
87
|
+
!!typeAnnotation && !!typeAnnotation.typeAnnotation && typeAnnotation.typeAnnotation.type === "TSUnknownKeyword"
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A value declared `unknown` is cast straight to a concrete type with no visible check first.
|
|
91
|
+
*
|
|
92
|
+
* `unknown` refuses every operation until narrowed -- except a cast, which TypeScript allows
|
|
93
|
+
* FREELY from `unknown` to anything, no guard required. `no-double-cast` catches the pair that
|
|
94
|
+
* launders a wrong type through `unknown`; it does not catch a single cast that starts already
|
|
95
|
+
* `unknown` and asserts straight past the type system with nothing behind it. That gap is TYPE-1's
|
|
96
|
+
* second half: the law asks for "unknown, narrowed exactly once, visibly", and a bare cast narrows
|
|
97
|
+
* nothing -- it only claims to.
|
|
98
|
+
*
|
|
99
|
+
* Scoped to identifiers explicitly annotated `unknown` (a parameter or a `const x: unknown`), and
|
|
100
|
+
* to narrowing found anywhere earlier in the SAME enclosing function -- not a full control-flow
|
|
101
|
+
* proof, because this plugin carries no type information to build one. The forms it accepts as
|
|
102
|
+
* narrowing are `typeof`, `instanceof`, `in`, a null/undefined equality check, and -- deliberately
|
|
103
|
+
* loose -- passing the value into ANY function call, because the law names "a predicate" beside
|
|
104
|
+
* `typeof`/`instanceof` and a custom guard is exactly that predicate; this rule cannot read what
|
|
105
|
+
* is inside the call, so it trusts that a call taking the value is a check on it. That looseness
|
|
106
|
+
* trades missed gaps for avoided false positives on purpose. The test lanes are exempt for the
|
|
107
|
+
* same reason `no-double-cast` exempts them: a harness helper that hands a caller-supplied
|
|
108
|
+
* generic type back unchecked (`nextMessage<TPayload>`) is deliberate, not a gap. Measured against
|
|
109
|
+
* the reference backend, this rule still stays at `warn` -- the debt is real and small enough to
|
|
110
|
+
* burn down, not yet zero.
|
|
111
|
+
*/
|
|
112
|
+
export const noUnguardedUnknownCast = {
|
|
113
|
+
meta: {
|
|
114
|
+
type: "problem",
|
|
115
|
+
docs: {
|
|
116
|
+
description:
|
|
117
|
+
"A value declared `unknown` is narrowed by a visible check before it is cast to a concrete type (TYPE-1, law 2).",
|
|
118
|
+
},
|
|
119
|
+
schema: [],
|
|
120
|
+
messages: {
|
|
121
|
+
unguarded:
|
|
122
|
+
"`{{name}}` entered as `unknown` and is cast straight to `{{target}}` with no `typeof`, `instanceof`, `in` or null check on `{{name}}` anywhere earlier in this function. A cast out of `unknown` is unrestricted by design -- TypeScript will not stop you -- so the check has to be the thing that stops you. Add one before the cast: `typeof {{name}} === \"...\"`, `{{name}} instanceof ...`, or `{{name}} !== null`.",
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
create(context) {
|
|
126
|
+
// the harness lane builds generic pass-through helpers on purpose (`nextMessage<TPayload>`
|
|
127
|
+
// handing back whatever the event bus produced as the caller's declared type) -- the same
|
|
128
|
+
// "deliberately unchecked on purpose" property TYPE-2 already carves the test lanes out for
|
|
129
|
+
if (isTestFile(context.filename || context.getFilename())) return {}
|
|
130
|
+
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
131
|
+
const trackedByScope = new Map()
|
|
132
|
+
const narrowedByScope = new Map()
|
|
133
|
+
const scopeKey = (node) => enclosingFunction(node) || "module"
|
|
134
|
+
|
|
135
|
+
const track = (name, scope) => {
|
|
136
|
+
if (!trackedByScope.has(scope)) trackedByScope.set(scope, new Set())
|
|
137
|
+
trackedByScope.get(scope).add(name)
|
|
138
|
+
}
|
|
139
|
+
const markNarrowed = (name, scope) => {
|
|
140
|
+
if (!narrowedByScope.has(scope)) narrowedByScope.set(scope, new Set())
|
|
141
|
+
narrowedByScope.get(scope).add(name)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const checkParams = (fnNode) => {
|
|
145
|
+
for (const raw of fnNode.params) {
|
|
146
|
+
const param = unwrapParam(raw)
|
|
147
|
+
if (param.type === "Identifier" && isUnknownAnnotation(param.typeAnnotation)) {
|
|
148
|
+
track(param.name, fnNode)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
FunctionDeclaration(node) {
|
|
155
|
+
checkParams(node)
|
|
156
|
+
},
|
|
157
|
+
FunctionExpression(node) {
|
|
158
|
+
checkParams(node)
|
|
159
|
+
},
|
|
160
|
+
ArrowFunctionExpression(node) {
|
|
161
|
+
checkParams(node)
|
|
162
|
+
},
|
|
163
|
+
VariableDeclarator(node) {
|
|
164
|
+
if (node.id.type === "Identifier" && isUnknownAnnotation(node.id.typeAnnotation)) {
|
|
165
|
+
track(node.id.name, scopeKey(node))
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
UnaryExpression(node) {
|
|
169
|
+
if (node.operator !== "typeof") return
|
|
170
|
+
if (node.argument.type !== "Identifier") return
|
|
171
|
+
markNarrowed(node.argument.name, scopeKey(node))
|
|
172
|
+
},
|
|
173
|
+
BinaryExpression(node) {
|
|
174
|
+
if (node.operator === "instanceof") {
|
|
175
|
+
if (node.left.type === "Identifier") markNarrowed(node.left.name, scopeKey(node))
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
if (node.operator === "in") {
|
|
179
|
+
if (node.right.type === "Identifier") markNarrowed(node.right.name, scopeKey(node))
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
if (!["===", "!==", "==", "!="].includes(node.operator)) return
|
|
183
|
+
if (node.left.type === "Identifier" && isNullOrUndefinedLiteral(node.right)) {
|
|
184
|
+
markNarrowed(node.left.name, scopeKey(node))
|
|
185
|
+
}
|
|
186
|
+
if (node.right.type === "Identifier" && isNullOrUndefinedLiteral(node.left)) {
|
|
187
|
+
markNarrowed(node.right.name, scopeKey(node))
|
|
188
|
+
}
|
|
189
|
+
},
|
|
190
|
+
CallExpression(node) {
|
|
191
|
+
// any predicate call that TAKES the value narrows it, the same way `typeof` does -- the
|
|
192
|
+
// law names "a predicate" beside `typeof`/`instanceof` on purpose, and a custom guard
|
|
193
|
+
// (`this.isPlainObject(current)`, `Array.isArray(value)`) is exactly that predicate. This
|
|
194
|
+
// side is deliberately loose: the cost of missing a real gap here is smaller than the cost
|
|
195
|
+
// of flagging a guard this rule cannot read the inside of.
|
|
196
|
+
for (const arg of node.arguments) {
|
|
197
|
+
if (arg.type === "Identifier") markNarrowed(arg.name, scopeKey(node))
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
TSAsExpression(node) {
|
|
201
|
+
if (node.expression.type !== "Identifier") return
|
|
202
|
+
const name = node.expression.name
|
|
203
|
+
const scope = scopeKey(node)
|
|
204
|
+
if (!trackedByScope.get(scope) || !trackedByScope.get(scope).has(name)) return
|
|
205
|
+
const target = node.typeAnnotation
|
|
206
|
+
if (target && target.type === "TSUnknownKeyword") return
|
|
207
|
+
if (narrowedByScope.get(scope) && narrowedByScope.get(scope).has(name)) return
|
|
208
|
+
context.report({
|
|
209
|
+
node,
|
|
210
|
+
messageId: "unguarded",
|
|
211
|
+
data: { name, target: sourceCode.getText(target) },
|
|
212
|
+
})
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
}
|
|
217
|
+
|
|
58
218
|
// -- TYPE-3 ----------------------------------------------------------------------------------------
|
|
59
219
|
|
|
60
220
|
/** A destructured parameter's type is named, so a second caller can reference it. */
|
|
@@ -116,11 +276,83 @@ export const noConstEnum = {
|
|
|
116
276
|
},
|
|
117
277
|
}
|
|
118
278
|
|
|
279
|
+
// -- TYPE-5 (rule 6) -- ATTEMPTED, NOT SHIPPED ------------------------------------------------------
|
|
280
|
+
//
|
|
281
|
+
// A rule was written here: two-or-more `is`/`has`-prefixed booleans sharing a type literal with a
|
|
282
|
+
// field that is optional and not itself boolean. It was measured against the reference backend
|
|
283
|
+
// before shipping, per this canon's own standard -- and both of its two real firings were wrong.
|
|
284
|
+
// `GlobalSearchItem` (`isEnrolled` / `isFree` / `isPremium` beside an unrelated optional
|
|
285
|
+
// `parentPath`) and `AdvertisementSeedItem` (`isHouseAd` / `isActive` beside an unrelated optional
|
|
286
|
+
// `ctaText`) are both genuinely independent facts about one entity, not a product of states -- the
|
|
287
|
+
// exact "transport type" and "independent booleans" exceptions `type-safety.md` already names. A
|
|
288
|
+
// rule reporting a defect on 2 of its 2 real firings is not a rule with debt to burn down; it is
|
|
289
|
+
// wrong, and shipping it at any level would teach a reader to distrust the machine rather than the
|
|
290
|
+
// code. TYPE-5 stays `documented`, exactly as the law's own `Layer held` table already says: seeing
|
|
291
|
+
// past a transport DTO to whether its flags are truly one situation needs the meaning of the code,
|
|
292
|
+
// which is a reader's job, not a decidable question of syntax.
|
|
293
|
+
|
|
294
|
+
// -- TYPE-6 (rule 7) ----------------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
/** This law's own published rule names -- the only ones this rule polices. */
|
|
297
|
+
const TYPE_SAFETY_RULE_NAMES = [
|
|
298
|
+
"no-double-cast",
|
|
299
|
+
"no-inline-param-type",
|
|
300
|
+
"no-const-enum",
|
|
301
|
+
"no-unguarded-unknown-cast",
|
|
302
|
+
]
|
|
303
|
+
|
|
304
|
+
const DISABLE_DIRECTIVE = /^eslint-disable(?:-next-line|-line)?\b/
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* A sanctioned exit is declared at the lane, once -- not suppressed per line.
|
|
308
|
+
*
|
|
309
|
+
* `no-double-cast`'s test-lane exemption already lives in `isTestFile`, consulted once, so every
|
|
310
|
+
* place the exit is in force is derivable from that one function. A per-line `eslint-disable` for
|
|
311
|
+
* any rule this law publishes is therefore never the lane speaking -- it is either redundant (the
|
|
312
|
+
* lane already covers the file) or an undeclared second exit (the file needed one and reached for
|
|
313
|
+
* a comment instead of a lane). Either way `type-safety.md` names this exact failure: "a per-line
|
|
314
|
+
* suppression standing in for a lane-wide exit".
|
|
315
|
+
*
|
|
316
|
+
* Deliberately scoped to only the five rule names this law publishes -- a rule that policed every
|
|
317
|
+
* `eslint-disable` in the repository would be reaching into every other family's lane, which is
|
|
318
|
+
* outside this file's boundary.
|
|
319
|
+
*/
|
|
320
|
+
export const noLineSuppression = {
|
|
321
|
+
meta: {
|
|
322
|
+
type: "problem",
|
|
323
|
+
docs: {
|
|
324
|
+
description:
|
|
325
|
+
"No inline `eslint-disable` for a type-safety rule; the lane is the only sanctioned exit (TYPE-6, law 7).",
|
|
326
|
+
},
|
|
327
|
+
schema: [],
|
|
328
|
+
messages: {
|
|
329
|
+
suppression:
|
|
330
|
+
"This comment disables `starci-be/{{name}}` on one line. That rule's only sanctioned exit is declared once, at the lane, inside the rule itself -- a per-line suppression is never that declaration, it is a stand-in for one. Delete the comment; if the file genuinely needs the exemption, it belongs in the lane the rule already recognizes.",
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
create(context) {
|
|
334
|
+
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
335
|
+
return {
|
|
336
|
+
"Program:exit"(node) {
|
|
337
|
+
for (const comment of sourceCode.getAllComments()) {
|
|
338
|
+
const text = comment.value.trim()
|
|
339
|
+
if (!DISABLE_DIRECTIVE.test(text)) continue
|
|
340
|
+
const named = TYPE_SAFETY_RULE_NAMES.find((name) => text.includes(`starci-be/${name}`))
|
|
341
|
+
if (!named) continue
|
|
342
|
+
context.report({ node: comment ?? node, messageId: "suppression", data: { name: named } })
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
|
|
119
349
|
/** The rules this law contributes to the plugin. */
|
|
120
350
|
export const rules = {
|
|
121
351
|
"no-double-cast": noDoubleCast,
|
|
122
352
|
"no-inline-param-type": noInlineParamType,
|
|
123
353
|
"no-const-enum": noConstEnum,
|
|
354
|
+
"no-unguarded-unknown-cast": noUnguardedUnknownCast,
|
|
355
|
+
"no-line-suppression": noLineSuppression,
|
|
124
356
|
}
|
|
125
357
|
|
|
126
358
|
/**
|
|
@@ -138,6 +370,8 @@ export const recommended = {
|
|
|
138
370
|
"starci-be/no-double-cast": "error",
|
|
139
371
|
"starci-be/no-inline-param-type": "error",
|
|
140
372
|
"starci-be/no-const-enum": "error",
|
|
373
|
+
"starci-be/no-unguarded-unknown-cast": "warn", // no=3 -- see type-safety.mjs's doc comment
|
|
374
|
+
"starci-be/no-line-suppression": "error",
|
|
141
375
|
"@typescript-eslint/no-explicit-any": "error",
|
|
142
376
|
"@typescript-eslint/array-type": ["error", {
|
|
143
377
|
default: "generic",
|