@starci/eslint-canon-fe 4.0.0 → 5.1.2

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.
Files changed (52) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +34 -16
  3. package/brand-values.mjs +174 -0
  4. package/class-names.mjs +1 -1
  5. package/client-boundary.mjs +199 -0
  6. package/comments.mjs +144 -190
  7. package/docs/brand-values.md +30 -0
  8. package/docs/client-boundary.md +99 -0
  9. package/docs/e2e-shape.md +161 -0
  10. package/docs/env-owner.md +52 -0
  11. package/docs/formatting.md +30 -0
  12. package/docs/hooks-folder.md +29 -0
  13. package/docs/hygiene.md +111 -0
  14. package/docs/lint-escape-hatch.md +32 -0
  15. package/docs/lists.md +74 -0
  16. package/docs/native-controls.md +73 -0
  17. package/docs/next-conventions.md +206 -0
  18. package/docs/size-and-state-budget.md +73 -0
  19. package/docs/spec-quality.md +139 -0
  20. package/docs/translation.md +50 -0
  21. package/docs/transport.md +168 -0
  22. package/docs/type-safety.md +95 -0
  23. package/e2e-shape.mjs +348 -0
  24. package/env-owner.mjs +134 -0
  25. package/file-layout.mjs +1 -2
  26. package/formatting.mjs +106 -0
  27. package/hooks-folder.mjs +119 -0
  28. package/hygiene.mjs +235 -0
  29. package/icon.mjs +210 -211
  30. package/index.mjs +290 -131
  31. package/lib/ast.mjs +94 -0
  32. package/lib/path.mjs +2 -0
  33. package/lib/scope.mjs +42 -0
  34. package/lib/why.mjs +304 -0
  35. package/lint-adoption.mjs +15 -52
  36. package/lint-escape-hatch.mjs +91 -56
  37. package/lists.mjs +197 -0
  38. package/loading.mjs +163 -164
  39. package/naming.mjs +143 -143
  40. package/native-controls.mjs +112 -0
  41. package/next-conventions.mjs +346 -0
  42. package/package.json +8 -4
  43. package/served-locale.mjs +160 -160
  44. package/shape-slot.mjs +1 -2
  45. package/size-and-state-budget.mjs +196 -0
  46. package/spec-quality.mjs +239 -0
  47. package/the-split.mjs +133 -134
  48. package/tokens.mjs +267 -268
  49. package/translation.mjs +262 -122
  50. package/transport.mjs +404 -0
  51. package/type-safety.mjs +156 -74
  52. package/typography.mjs +82 -83
package/transport.mjs ADDED
@@ -0,0 +1,404 @@
1
+ /**
2
+ * The rules that hold `transport.md` (HFS R50 `FE_TRANSPORT_OWNER`, R51 `FE_HTTP_STATUS_COLLAPSE`,
3
+ * R52 `FE_WIRE_GENERATED`).
4
+ *
5
+ * ONE WIRE, ONE VOCABULARY. An app talks to its backend through exactly one module,
6
+ * `modules/api/client.ts`, and that module answers in exactly one vocabulary, `Outcome<T>`. The
7
+ * defects the three halves prevent were measured on real apps: seven transports and six result
8
+ * shapes in one repository, and in another a client that folded every non-2xx into "could not
9
+ * read", so the state a real backend produces for a signed-out reader - 401, which must become
10
+ * `refused` and send the reader to sign in - was unreachable, while the e2e double hid it by
11
+ * answering 200.
12
+ *
13
+ * WHAT THESE RULES CANNOT SEE. They read one file at a time, so they cannot tell that a client's
14
+ * timeout is long enough or that a `refused` outcome is routed to the sign-in page - the module's
15
+ * spec proves that. They hold the shapes that make it provable: one `fetch`, an abort signal on
16
+ * it, no shared mutable state, a 401/403 branch, and no null standing in for a status.
17
+ */
18
+
19
+ import { isApiClient, isSpecFile } from "./lib/scope.mjs"
20
+ import { normalizePath } from "./lib/path.mjs"
21
+
22
+ /** Any other way to send an HTTP request: a second transport is a second owner. */
23
+ const OTHER_TRANSPORTS = /^(?:axios|ky|ky-universal|got|node-fetch|undici|cross-fetch|isomorphic-fetch|superagent|ofetch|whatwg-fetch)$/
24
+
25
+ /** `fetch`, `globalThis.fetch`, `window.fetch`, `self.fetch`. */
26
+ const isFetchCall = (node) => {
27
+ const callee = node.callee
28
+ if (callee.type === "Identifier") return callee.name === "fetch"
29
+ return (
30
+ callee.type === "MemberExpression" &&
31
+ !callee.computed &&
32
+ callee.property.name === "fetch" &&
33
+ callee.object.type === "Identifier" &&
34
+ ["globalThis", "window", "self", "global"].includes(callee.object.name)
35
+ )
36
+ }
37
+
38
+ /** Files under `modules/api/` that the wire law governs (the contract copy and generated output are data). */
39
+ const isApiModuleFile = (filename) => {
40
+ const file = normalizePath(filename)
41
+ return /\/modules\/api\//.test(file) && !/\/(?:contract|__generated__)\//.test(file)
42
+ }
43
+
44
+ // -- FE-TRANSPORT-1 --------------------------------------------------------------------------------
45
+
46
+ /** `fetch` is called in one module, and no other HTTP library exists. */
47
+ export const fetchOnlyInApiClient = {
48
+ meta: {
49
+ type: "problem",
50
+ docs: { description: "The app's single `fetch` lives in `modules/api/client.ts`." },
51
+ schema: [],
52
+ messages: {
53
+ outside:
54
+ "`fetch` outside `modules/api/client.ts`. The app has exactly one transport; a second one is a second place that must remember the timeout, the credential and the status mapping, and it will forget one. Call the client and take its `Outcome`.",
55
+ library:
56
+ "`{{name}}` is a second HTTP transport. The app's one client is `modules/api/client.ts`, built on `fetch`; another library there is another set of timeout, retry and error rules. Use the client.",
57
+ xhr: "`XMLHttpRequest` is a second transport. Use `modules/api/client.ts`.",
58
+ },
59
+ },
60
+ create(context) {
61
+ const filename = context.filename || context.getFilename()
62
+ if (isSpecFile(filename)) return {}
63
+ const client = isApiClient(filename)
64
+ return {
65
+ CallExpression(node) {
66
+ if (!client && isFetchCall(node)) context.report({ node, messageId: "outside" })
67
+ },
68
+ NewExpression(node) {
69
+ if (node.callee.type === "Identifier" && node.callee.name === "XMLHttpRequest") {
70
+ context.report({ node, messageId: "xhr" })
71
+ }
72
+ },
73
+ ImportDeclaration(node) {
74
+ const source = String(node.source.value)
75
+ const name = source.split("/")[0]
76
+ if (OTHER_TRANSPORTS.test(name)) context.report({ node, messageId: "library", data: { name: source } })
77
+ },
78
+ }
79
+ },
80
+ }
81
+
82
+ // -- FE-TRANSPORT-2 --------------------------------------------------------------------------------
83
+
84
+ /** The client's `fetch` carries an abort signal, so a hung backend cannot hang a reader. */
85
+ export const clientFetchHasSignal = {
86
+ meta: {
87
+ type: "problem",
88
+ docs: { description: "Every `fetch` in `modules/api/client.ts` passes an AbortSignal (timeout or caller)." },
89
+ schema: [],
90
+ messages: {
91
+ signal:
92
+ "This `fetch` carries no `signal`. A request with no abort signal waits as long as the backend does, and the reader waits with it. Pass `signal` (an `AbortSignal.timeout(...)` combined with the caller's).",
93
+ },
94
+ },
95
+ create(context) {
96
+ if (!isApiClient(context.filename || context.getFilename())) return {}
97
+ return {
98
+ CallExpression(node) {
99
+ if (!isFetchCall(node)) return
100
+ const options = node.arguments[1]
101
+ if (!options) return context.report({ node, messageId: "signal" })
102
+ // A spread or a computed value may carry it; only a literal object that plainly lacks it is wrong.
103
+ if (options.type !== "ObjectExpression") return
104
+ const carries = options.properties.some(
105
+ (property) =>
106
+ property.type === "SpreadElement" ||
107
+ (property.type === "Property" && property.key.type === "Identifier" && property.key.name === "signal"),
108
+ )
109
+ if (!carries) context.report({ node, messageId: "signal" })
110
+ },
111
+ }
112
+ },
113
+ }
114
+
115
+ // -- FE-TRANSPORT-3 --------------------------------------------------------------------------------
116
+
117
+ /** No module-level mutable state in the API layer: a token or locale in a `let` leaks across requests. */
118
+ export const noSharedTransportState = {
119
+ meta: {
120
+ type: "problem",
121
+ docs: { description: "`modules/api/**` holds no module-level `let`/`var`." },
122
+ schema: [],
123
+ messages: {
124
+ shared:
125
+ "A module-level `{{kind}}` in the API layer. A token or locale kept there is shared by every request on the server and by every reader in one tab, so one reader's credential can answer another's request. Pass the credential as a parameter or read it from context.",
126
+ },
127
+ },
128
+ create(context) {
129
+ const filename = context.filename || context.getFilename()
130
+ if (!isApiModuleFile(filename) || isSpecFile(filename)) return {}
131
+ const check = (node) => {
132
+ const declaration = node.type === "VariableDeclaration" ? node : node.declaration
133
+ if (!declaration || declaration.type !== "VariableDeclaration" || declaration.kind === "const") return
134
+ context.report({ node: declaration, messageId: "shared", data: { kind: declaration.kind } })
135
+ }
136
+ return {
137
+ "Program > VariableDeclaration": check,
138
+ "Program > ExportNamedDeclaration": check,
139
+ }
140
+ },
141
+ }
142
+
143
+ // -- FE-TRANSPORT-4 --------------------------------------------------------------------------------
144
+
145
+ /** The client maps 401/403 to `refused`, so "sign in" is a state a reader can reach. */
146
+ export const clientMapsAuthToRefused = {
147
+ meta: {
148
+ type: "problem",
149
+ docs: { description: "`modules/api/client.ts` handles 401 and 403 and produces `refused`." },
150
+ schema: [],
151
+ messages: {
152
+ refused:
153
+ "This client calls `fetch` but does not map 401 and 403 to a `refused` outcome. Without that branch a signed-out reader is reported as \"could not load\" and never reaches the sign-in state.",
154
+ },
155
+ },
156
+ create(context) {
157
+ if (!isApiClient(context.filename || context.getFilename())) return {}
158
+ let fetches = false
159
+ const seen = { refused: false, 401: false, 403: false }
160
+ return {
161
+ CallExpression(node) {
162
+ if (isFetchCall(node)) fetches = true
163
+ },
164
+ Literal(node) {
165
+ if (node.value === "refused") seen.refused = true
166
+ if (node.value === 401) seen[401] = true
167
+ if (node.value === 403) seen[403] = true
168
+ },
169
+ "Program:exit"(program) {
170
+ if (fetches && !(seen.refused && seen[401] && seen[403])) {
171
+ context.report({ node: program, messageId: "refused" })
172
+ }
173
+ },
174
+ }
175
+ },
176
+ }
177
+
178
+ // -- FE-STATUS-1 -----------------------------------------------------------------------------------
179
+
180
+ /** The object of `x.ok`, else null. */
181
+ const okSubject = (node) => {
182
+ if (!node || node.type !== "MemberExpression" || node.computed || node.property.name !== "ok") return null
183
+ return node.object
184
+ }
185
+
186
+ /** True when a value carries nothing: `null`, `undefined`, `void 0`, nothing, `[]`, `{}`, `false`. */
187
+ const isEmptyValue = (node) => {
188
+ if (!node) return true
189
+ if (node.type === "Literal") return node.value === null || node.value === false
190
+ if (node.type === "Identifier") return node.name === "undefined"
191
+ if (node.type === "UnaryExpression") return node.operator === "void"
192
+ if (node.type === "ArrayExpression") return node.elements.length === 0
193
+ if (node.type === "ObjectExpression") return node.properties.length === 0
194
+ return false
195
+ }
196
+
197
+ /**
198
+ * The response a "not ok" test is about, else null.
199
+ *
200
+ * `!res.ok`, `res.ok === false`, `res.ok !== true` and, since a status compare is the same collapse
201
+ * spelled with a number, `res.status !== 200`, `res.status >= 400` or `res.status === 404`.
202
+ */
203
+ const failureSubject = (test) => {
204
+ if (test.type === "UnaryExpression" && test.operator === "!") return okSubject(test.argument)
205
+ if (test.type !== "BinaryExpression") return null
206
+ for (const [side, other] of [
207
+ [test.left, test.right],
208
+ [test.right, test.left],
209
+ ]) {
210
+ const subject = okSubject(side)
211
+ if (subject && other.type === "Literal") {
212
+ if ((test.operator === "===" || test.operator === "==") && other.value === false) return subject
213
+ if ((test.operator === "!==" || test.operator === "!=") && other.value === true) return subject
214
+ }
215
+ const isStatus = side.type === "MemberExpression" && !side.computed && side.property.name === "status"
216
+ if (isStatus && other.type === "Literal" && typeof other.value === "number") {
217
+ if (side === test.left) return side.object
218
+ }
219
+ }
220
+ return null
221
+ }
222
+
223
+ /** Statements of a branch body. */
224
+ const bodyStatements = (node) => (node.type === "BlockStatement" ? node.body : [node])
225
+
226
+ /** True when a subtree mentions a status or the tested response itself, so it can tell the codes apart. */
227
+ const inspectsResponse = (text, subjectName) =>
228
+ /\bstatus\b/.test(text) || (subjectName !== null && new RegExp(`\\b${subjectName}\\b`).test(text))
229
+
230
+ /** A failed response is not one branch, and never `null`. */
231
+ export const noHttpStatusCollapse = {
232
+ meta: {
233
+ type: "problem",
234
+ docs: { description: "A non-ok response is not folded into one branch or into null; 401/403 become `refused`." },
235
+ schema: [],
236
+ messages: {
237
+ empty:
238
+ "A failed response becomes an empty value here. `null` says nothing about WHY: the reader who is signed out, the record that does not exist and the backend that is down all look identical, so the screen can show only one message. Return an `Outcome` (`refused`, `not-found`, `invalid`, `unavailable`) chosen by status; 401/403 must be `refused`.",
239
+ collapse:
240
+ "Every failed status takes this one branch and the response is never inspected. 401/403 must become `refused`, 404 `not-found`, 422 `invalid`, the rest `unavailable`. Branch on `response.status`.",
241
+ raw: "The server's own text is used as the reason. That text is written for a developer, may leak internals, and is in the wrong language. Map the status to a reason code and let the screen translate it.",
242
+ },
243
+ },
244
+ create(context) {
245
+ if (isSpecFile(context.filename || context.getFilename())) return {}
246
+ const source = context.sourceCode || context.getSourceCode()
247
+ return {
248
+ IfStatement(node) {
249
+ const subject = failureSubject(node.test)
250
+ if (!subject) return
251
+ const subjectName = subject.type === "Identifier" ? subject.name : null
252
+ const statements = bodyStatements(node.consequent)
253
+ const returns = statements.filter((statement) => statement.type === "ReturnStatement")
254
+ if (returns.some((statement) => isEmptyValue(statement.argument))) {
255
+ context.report({ node, messageId: "empty" })
256
+ return
257
+ }
258
+ // A branch on one named code (`status === 404`) is already inspecting the response.
259
+ const test = node.test
260
+ const general =
261
+ test.type !== "BinaryExpression" || !/\.status\b/.test(source.getText(test)) || !/^={2,3}$/.test(test.operator)
262
+ const jumps = statements.filter((s) => s.type === "ReturnStatement" || s.type === "ThrowStatement")
263
+ if (general && jumps.length > 0 && !jumps.some((s) => inspectsResponse(source.getText(s), subjectName))) {
264
+ context.report({ node, messageId: "collapse" })
265
+ }
266
+ },
267
+ ConditionalExpression(node) {
268
+ if (okSubject(node.test) && isEmptyValue(node.alternate)) return context.report({ node, messageId: "empty" })
269
+ if (failureSubject(node.test) && isEmptyValue(node.consequent)) context.report({ node, messageId: "empty" })
270
+ },
271
+ Property(node) {
272
+ if (node.computed || node.key.type !== "Identifier" || !["reason", "message"].includes(node.key.name)) return
273
+ const text = source.getText(node.value)
274
+ const rawText =
275
+ /\.statusText\b/.test(text) || (node.value.type === "AwaitExpression" && /\.(?:text|json)\(\)/.test(text))
276
+ if (rawText) context.report({ node: node.value, messageId: "raw" })
277
+ },
278
+ }
279
+ },
280
+ }
281
+
282
+ // -- FE-WIRE-1 -------------------------------------------------------------------------------------
283
+
284
+ /** `x.json()`, possibly awaited. */
285
+ const isJsonRead = (node) => {
286
+ let current = node
287
+ while (current && (current.type === "AwaitExpression" || current.type === "TSNonNullExpression")) {
288
+ current = current.argument || current.expression
289
+ }
290
+ return Boolean(
291
+ current &&
292
+ current.type === "CallExpression" &&
293
+ current.callee.type === "MemberExpression" &&
294
+ !current.callee.computed &&
295
+ current.callee.property.name === "json",
296
+ )
297
+ }
298
+
299
+ /** A GraphQL operation written as text. */
300
+ const GRAPHQL_TEXT = /^\s*(?:query|mutation|subscription|fragment)\b[^{]*\{/
301
+
302
+ /** Wire types are generated from the contract; nobody types the wire by hand. */
303
+ export const noHandTypedWire = {
304
+ meta: {
305
+ type: "problem",
306
+ docs: { description: "Wire types come from the contract copy via codegen; responses are not cast." },
307
+ schema: [],
308
+ messages: {
309
+ cast:
310
+ "A response body is cast to a type here. A cast is a claim nobody checks: when the backend changes the shape this still compiles and fails at a reader's screen. Use the type generated from `modules/api/contract`, and validate at the client.",
311
+ graphqlType:
312
+ "`{{name}}` is a hand-written GraphQL result type used as a cast. The generated document type already says exactly what this operation returns; use it.",
313
+ document:
314
+ "A GraphQL document written inline in TypeScript. Documents live in `.graphql` files so codegen can generate their types and the contract check can read them.",
315
+ declared:
316
+ "`{{name}}` declares a wire shape by hand in `modules/api`. Wire types are generated from the backend contract (`codegen`), so a hand-written copy is a second source of truth that drifts. Import the generated type.",
317
+ },
318
+ },
319
+ create(context) {
320
+ const filename = context.filename || context.getFilename()
321
+ if (isSpecFile(filename)) return {}
322
+ const inApi = isApiModuleFile(filename)
323
+ const castCheck = (node) => {
324
+ const type = node.typeAnnotation
325
+ if (type && type.type === "TSTypeReference" && type.typeName.type === "Identifier") {
326
+ if (/^Graph[Qq][Ll]\w*$/.test(type.typeName.name)) {
327
+ context.report({ node, messageId: "graphqlType", data: { name: type.typeName.name } })
328
+ return
329
+ }
330
+ }
331
+ if (isJsonRead(node.expression)) context.report({ node, messageId: "cast" })
332
+ }
333
+ const declared = (node) => {
334
+ if (!inApi || !/(?:Wire|Dto|DTO|Response|Payload)$/.test(node.id.name)) return
335
+ context.report({ node: node.id, messageId: "declared", data: { name: node.id.name } })
336
+ }
337
+ return {
338
+ TSAsExpression: castCheck,
339
+ TSTypeAssertion: castCheck,
340
+ TaggedTemplateExpression(node) {
341
+ if (node.tag.type === "Identifier" && /^(?:gql|graphql)$/.test(node.tag.name)) {
342
+ context.report({ node, messageId: "document" })
343
+ }
344
+ },
345
+ TemplateLiteral(node) {
346
+ if (node.parent && node.parent.type === "TaggedTemplateExpression") return
347
+ const text = node.quasis[0] && node.quasis[0].value.cooked
348
+ if (typeof text === "string" && GRAPHQL_TEXT.test(text)) context.report({ node, messageId: "document" })
349
+ },
350
+ Literal(node) {
351
+ if (typeof node.value === "string" && GRAPHQL_TEXT.test(node.value) && node.value.includes("}")) {
352
+ context.report({ node, messageId: "document" })
353
+ }
354
+ },
355
+ TSInterfaceDeclaration: declared,
356
+ TSTypeAliasDeclaration: declared,
357
+ }
358
+ },
359
+ }
360
+
361
+ // -- TRANSPORT-7 -----------------------------------------------------------------------------------
362
+
363
+ /** The kinds of `Outcome<T>` (HFS section 6.2): a reader is owed a screen for each. */
364
+ const OUTCOME_KINDS = ["ok", "refused", "invalid", "not-found", "unavailable"]
365
+
366
+ /** A switch over an Outcome names every kind, so a failure never falls into the success branch or a blank one. */
367
+ export const outcomeKindsExhaustive = {
368
+ meta: {
369
+ type: "problem",
370
+ docs: { description: "A `switch` over an Outcome's `kind` has a case for every kind." },
371
+ schema: [],
372
+ messages: {
373
+ missing:
374
+ "This `switch` handles `ok` but not {{missing}}. Each kind is a screen a reader can land on: refused sends them to sign in, invalid shows what to fix, not-found says it is gone, unavailable says try again. A missing case is a blank page, or a `default` that shows the wrong one. Write every case; do not rely on `default`.",
375
+ },
376
+ },
377
+ create(context) {
378
+ if (isSpecFile(context.filename || context.getFilename())) return {}
379
+ return {
380
+ SwitchStatement(node) {
381
+ const subject = node.discriminant
382
+ if (subject.type !== "MemberExpression" || subject.computed || subject.property.type !== "Identifier" || subject.property.name !== "kind") return
383
+ const cases = new Set(node.cases.filter((entry) => entry.test && entry.test.type === "Literal").map((entry) => entry.test.value))
384
+ if (!cases.has("ok")) return
385
+ const missing = OUTCOME_KINDS.filter((kind) => !cases.has(kind))
386
+ if (missing.length > 0) context.report({ node, messageId: "missing", data: { missing: missing.map((kind) => `\`${kind}\``).join(", ") } })
387
+ },
388
+ }
389
+ },
390
+ }
391
+
392
+ /** The rules this law contributes to the plugin. */
393
+ export const rules = {
394
+ "fetch-only-in-api-client": fetchOnlyInApiClient,
395
+ "client-fetch-has-signal": clientFetchHasSignal,
396
+ "no-shared-transport-state": noSharedTransportState,
397
+ "client-maps-auth-to-refused": clientMapsAuthToRefused,
398
+ "no-http-status-collapse": noHttpStatusCollapse,
399
+ "no-hand-typed-wire": noHandTypedWire,
400
+ "outcome-kinds-exhaustive": outcomeKindsExhaustive,
401
+ }
402
+
403
+ /** Every rule is an error: a second transport or a status collapse is the defect, not a style. */
404
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/type-safety.mjs CHANGED
@@ -1,74 +1,156 @@
1
- /**
2
- * The rules that hold `type-safety.md`.
3
- *
4
- * ONE RULE HERE, AND TWO ELSEWHERE. `any` is refused by the TypeScript plugin's own rule and the
5
- * array spelling by its formatter rule, so neither is reimplemented - a second copy of somebody
6
- * else's rule is a second thing to keep in step, and the one nobody edits is the one that stops
7
- * matching. What is left is the double cast, which no off-the-shelf rule refuses because most
8
- * codebases consider it a legitimate escape.
9
- *
10
- * THE EXEMPTION IS A PATH, and it has to be. Proving a closed API refuses bad input means building
11
- * bad input, and there is no way to construct a value the types forbid without telling the compiler
12
- * to forget them. A judgement-based exemption would be argued at every call site; a path is argued
13
- * once, here.
14
- */
15
-
16
- /** Forward-slash form of a filename, so Windows paths compare like every other path. */
17
- const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
18
-
19
- /** Product source lives under `src/`; tooling and config are out of scope. */
20
- const isSourceFile = (filename) => normalizePath(filename).includes("/src/")
21
-
22
- /** A test may build a value the types forbid, because that is what it is proving. */
23
- const isTestFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx)$/.test(normalizePath(filename))
24
-
25
- /** Product source this rule governs. */
26
- const isGoverned = (filename) => isSourceFile(filename) && !isTestFile(filename)
27
-
28
- /** True when a type node is the `unknown` keyword. */
29
- const isUnknown = (node) => Boolean(node) && node.type === "TSUnknownKeyword"
30
-
31
- // -- TYPE-SAFETY-1 ---------------------------------------------------------------------------------
32
-
33
- /** A cast through `unknown` erases what the compiler knew, at the seam where it mattered. */
34
- export const noDoubleCast = {
35
- meta: {
36
- type: "problem",
37
- docs: { description: "No cast through `unknown`; it turns checking off at the boundary." },
38
- schema: [],
39
- messages: {
40
- double:
41
- "Casting through `unknown` tells the compiler to forget everything it knew about this value, because the two types have nothing in common. That is not a narrowing - a narrowing is a claim the compiler can still partly check - it is an erasure, and the seam it erases is the one worth checking: where a value crosses from outside the program to inside it. Narrow from `unknown` with a check the compiler can follow, or fix the shape.",
42
- },
43
- },
44
- create(context) {
45
- if (!isGoverned(context.filename || context.getFilename())) return {}
46
- return {
47
- TSAsExpression(node) {
48
- // The outer cast of a `x as unknown as T` pair: its operand is itself a cast to `unknown`.
49
- const inner = node.expression
50
- if (!inner || inner.type !== "TSAsExpression") return
51
- if (!isUnknown(inner.typeAnnotation)) return
52
- context.report({ node, messageId: "double" })
53
- },
54
- }
55
- },
56
- }
57
-
58
- /** The rules this law contributes to the plugin. */
59
- export const rules = {
60
- "no-double-cast": noDoubleCast,
61
- }
62
-
63
- /**
64
- * The level this law asks for, as the plugin's own opinion.
65
- *
66
- * Exact - it matches one syntactic shape - but a repository adopting it with history should expect
67
- * each report to be real work: a double cast is usually load-bearing by the time anybody notices,
68
- * and removing one means giving the value a shape it did not have.
69
- *
70
- * `any` and the array spelling are NOT published here. They belong to the TypeScript plugin's own
71
- * rules, which a consuming repository already has: `@typescript-eslint/no-explicit-any` and
72
- * `@typescript-eslint/array-type` with the generic default.
73
- */
74
- export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
1
+ /**
2
+ * The rules that hold `type-safety.md`.
3
+ *
4
+ * FOUR RULES, ONE IDEA: the compiler is told the truth or it is not told at all. The double cast, the
5
+ * plain assertion, the non-null assertion and `any` are the four spellings of "trust me". They live
6
+ * here rather than in a repository's own `eslint.config.mjs` because a repository carries no rule of
7
+ * its own: a rule that exists only in one repository's config is a rule the next repository forgets.
8
+ * The array spelling is a formatting question and stays with the formatter.
9
+ *
10
+ * THE EXEMPTION IS A PATH, and it has to be. Proving a closed API refuses bad input means building
11
+ * bad input, and there is no way to construct a value the types forbid without telling the compiler
12
+ * to forget them. A judgement-based exemption would be argued at every call site; a path is argued
13
+ * once, here.
14
+ */
15
+
16
+ import { normalizePath } from "./lib/path.mjs"
17
+
18
+ /** Product source lives under `src/`; tooling and config are out of scope. */
19
+ const isSourceFile = (filename) => normalizePath(filename).includes("/src/")
20
+
21
+ /** A test may build a value the types forbid, because that is what it is proving. */
22
+ const isTestFile = (filename) => /\.(?:test|spec)\.(?:ts|tsx)$/.test(normalizePath(filename))
23
+
24
+ /** Product source this rule governs. */
25
+ const isGoverned = (filename) => isSourceFile(filename) && !isTestFile(filename)
26
+
27
+ /** True when a type node is the `unknown` keyword. */
28
+ const isUnknown = (node) => Boolean(node) && node.type === "TSUnknownKeyword"
29
+
30
+ // -- TYPE-SAFETY-1 ---------------------------------------------------------------------------------
31
+
32
+ /** A cast through `unknown` erases what the compiler knew, at the seam where it mattered. */
33
+ export const noDoubleCast = {
34
+ meta: {
35
+ type: "problem",
36
+ docs: { description: "No cast through `unknown`; it turns checking off at the boundary." },
37
+ schema: [],
38
+ messages: {
39
+ double:
40
+ "Casting through `unknown` tells the compiler to forget everything it knew about this value, because the two types have nothing in common. That is not a narrowing - a narrowing is a claim the compiler can still partly check - it is an erasure, and the seam it erases is the one worth checking: where a value crosses from outside the program to inside it. Narrow from `unknown` with a check the compiler can follow, or fix the shape.",
41
+ },
42
+ },
43
+ create(context) {
44
+ if (!isGoverned(context.filename || context.getFilename())) return {}
45
+ return {
46
+ TSAsExpression(node) {
47
+ // The outer cast of a `x as unknown as T` pair: its operand is itself a cast to `unknown`.
48
+ const inner = node.expression
49
+ if (!inner || inner.type !== "TSAsExpression") return
50
+ if (!isUnknown(inner.typeAnnotation)) return
51
+ context.report({ node, messageId: "double" })
52
+ },
53
+ }
54
+ },
55
+ }
56
+
57
+ // -- TYPE-SAFETY-2 ---------------------------------------------------------------------------------
58
+
59
+ /** True for `as const`, the one assertion that narrows without claiming anything. */
60
+ const isConstAssertion = (type) => type.type === "TSTypeReference" && type.typeName.type === "Identifier" && type.typeName.name === "const"
61
+
62
+ /**
63
+ * A type assertion is a claim the compiler cannot check, made at the place it was going to check.
64
+ *
65
+ * WHAT IS LEFT ALLOWED. `as const` (it narrows), `as unknown` (it widens, and everything is assignable
66
+ * to it), and `satisfies` (it checks). The cast through `unknown` to a real type is refused by
67
+ * `no-double-cast`, so this rule leaves the outer half of that pair to it and reports each once.
68
+ */
69
+ export const noTypeAssertion = {
70
+ meta: {
71
+ type: "problem",
72
+ docs: { description: "No `as T` or `<T>x`; narrow with a check, a guard or a parser." },
73
+ schema: [],
74
+ messages: {
75
+ assertion:
76
+ "`as {{type}}` tells the compiler to believe something it could not verify. When the claim is wrong the type system keeps saying it is right and the failure moves to a reader. Narrow with a check the compiler can follow (a type guard, `in`, a discriminant), parse the value where it enters, or use `satisfies` when the goal is only to check a literal.",
77
+ },
78
+ },
79
+ create(context) {
80
+ if (!isGoverned(context.filename || context.getFilename())) return {}
81
+ const source = context.sourceCode ?? context.getSourceCode()
82
+ const report = (node) =>
83
+ context.report({ node, messageId: "assertion", data: { type: source.getText(node.typeAnnotation) } })
84
+ return {
85
+ TSAsExpression(node) {
86
+ if (isConstAssertion(node.typeAnnotation) || isUnknown(node.typeAnnotation)) return
87
+ const inner = node.expression
88
+ if (inner && inner.type === "TSAsExpression" && isUnknown(inner.typeAnnotation)) return
89
+ report(node)
90
+ },
91
+ TSTypeAssertion(node) {
92
+ if (isConstAssertion(node.typeAnnotation)) return
93
+ report(node)
94
+ },
95
+ }
96
+ },
97
+ }
98
+
99
+ // -- TYPE-SAFETY-3 ---------------------------------------------------------------------------------
100
+
101
+ /** `x!` says a value is present without proving it. */
102
+ export const noNonNullAssertion = {
103
+ meta: {
104
+ type: "problem",
105
+ docs: { description: "No non-null assertion (`x!`); prove the value is there." },
106
+ schema: [],
107
+ messages: {
108
+ bang:
109
+ "`!` asserts the value is not null or undefined and proves nothing: the day it is one, the failure is a TypeError in a reader's browser rather than a branch in the code. Handle the absent case (`if`, `??`, an early return) or make the type say it cannot be absent.",
110
+ },
111
+ },
112
+ create(context) {
113
+ if (!isGoverned(context.filename || context.getFilename())) return {}
114
+ return { TSNonNullExpression: (node) => context.report({ node, messageId: "bang" }) }
115
+ },
116
+ }
117
+
118
+ // -- TYPE-SAFETY-4 ---------------------------------------------------------------------------------
119
+
120
+ /** `any` switches checking off for everything it touches. */
121
+ export const noExplicitAny = {
122
+ meta: {
123
+ type: "problem",
124
+ docs: { description: "No `any`; use a real type or `unknown` and narrow." },
125
+ schema: [],
126
+ messages: {
127
+ any:
128
+ "`any` turns type checking off for this value and everything derived from it, silently. Use the real type, a generic, or `unknown` and narrow it where it is used.",
129
+ },
130
+ },
131
+ create(context) {
132
+ if (!isGoverned(context.filename || context.getFilename())) return {}
133
+ return { TSAnyKeyword: (node) => context.report({ node, messageId: "any" }) }
134
+ },
135
+ }
136
+
137
+ /** The rules this law contributes to the plugin. */
138
+ export const rules = {
139
+ "no-double-cast": noDoubleCast,
140
+ "no-type-assertion": noTypeAssertion,
141
+ "no-non-null-assertion": noNonNullAssertion,
142
+ "no-explicit-any": noExplicitAny,
143
+ }
144
+
145
+ /**
146
+ * The level this law asks for, as the plugin's own opinion.
147
+ *
148
+ * Exact - it matches one syntactic shape - but a repository adopting it with history should expect
149
+ * each report to be real work: a double cast is usually load-bearing by the time anybody notices,
150
+ * and removing one means giving the value a shape it did not have.
151
+ *
152
+ * `any` and the array spelling are NOT published here. They belong to the TypeScript plugin's own
153
+ * rules, which a consuming repository already has: `@typescript-eslint/no-explicit-any` and
154
+ * `@typescript-eslint/array-type` with the generic default.
155
+ */
156
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))