@starci/eslint-canon-be 1.1.0 → 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/type-safety.mjs CHANGED
@@ -1,18 +1,23 @@
1
1
  /**
2
2
  * The rules that hold `type-safety.md`.
3
3
  *
4
- * Three house rules plus two standard ones, and the split is worth knowing. `no-explicit-any` and
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), the
10
- * inline parameter type (TYPE-3) and the const enum (TYPE-4). Each is a way of switching the
11
- * compiler off that looks locally reasonable, which is exactly the class a rule is for.
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 is not enforced. Whether a set of booleans describes ONE situation or several genuinely
14
- * independent ones needs to know what the code means, and a rule that guessed would fire on every
15
- * struct with two flags in it.
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",