@starci/eslint-canon-be 1.2.1 → 1.2.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.
package/type-safety.mjs CHANGED
@@ -1,511 +1,510 @@
1
- /**
2
- * The rules that hold `type-safety.md`.
3
- *
4
- * Six house rules plus two standard ones, and the split is worth knowing. `no-explicit-any` and
5
- * the array-type spelling already exist in the TypeScript plugin and are named in `recommended`
6
- * rather than reimplemented -- a second implementation of a rule everybody has is a maintenance
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.
10
- *
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 inline object type in every other position --
13
- * property, return, variable -- that rule 4 names once it is read without the word "destructured"
14
- * (TYPE-3, rule 4, extended; see the export below for its `recommended` level and the measurement
15
- * that set it), the const enum
16
- * (TYPE-4, rule 5), the unguarded cast off an `unknown` value (TYPE-1's second half, rule 2), and
17
- * the per-line suppression standing in for a lane (TYPE-6, rule 7). Each is a way of switching the
18
- * compiler off -- or of leaving it turned further down than the law asks -- that looks locally
19
- * reasonable, which is exactly the class a rule is for.
20
- *
21
- * TYPE-5 (rule 6, the boolean-flag product) is NOT among them, and that absence was tested rather
22
- * than assumed. See the comment where TYPE-5 would sit, below, for what was tried and why it was
23
- * withdrawn after being measured against the reference backend.
24
- */
25
-
26
- /** Forward-slash form of a filename, so Windows paths compare like every other path. */
27
- const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
28
-
29
- /** The spec family and the test tree may build a deliberately wrong value on purpose. */
30
- const isTestFile = (filename) => {
31
- const file = normalizePath(filename)
32
- return /\.(?:spec|test|e2e-spec|int-spec|harness-spec)\.ts$/.test(file) || file.includes("/src/tests/")
33
- }
34
-
35
- /** A parameter property (`private readonly x: T`) wraps the parameter it declares. */
36
- const unwrapParam = (param) => (param.type === "TSParameterProperty" ? param.parameter : param)
37
-
38
- // -- TYPE-2 ----------------------------------------------------------------------------------------
39
-
40
- /** A cast through `unknown` is the compiler being overruled twice. */
41
- export const noDoubleCast = {
42
- meta: {
43
- type: "problem",
44
- docs: { description: "No `x as unknown as T` outside the test lanes." },
45
- schema: [],
46
- messages: {
47
- doubleCast:
48
- "`as unknown as` is the compiler saying these types do not overlap, and being overruled twice. It is worse than `any` in one specific way: the result CLAIMS to be the target type, so everything downstream trusts it completely and the failure surfaces far from this line. Fix the type, or narrow with a guard that actually checks.",
49
- },
50
- },
51
- create(context) {
52
- if (isTestFile(context.filename || context.getFilename())) return {}
53
- return {
54
- TSAsExpression(node) {
55
- // the OUTER cast of the pair: its operand is itself a cast, to `unknown`
56
- const inner = node.expression
57
- if (!inner || inner.type !== "TSAsExpression") return
58
- const innerType = inner.typeAnnotation
59
- if (!innerType || innerType.type !== "TSUnknownKeyword") return
60
- context.report({ node, messageId: "doubleCast" })
61
- },
62
- }
63
- },
64
- }
65
-
66
- // -- TYPE-1, second half (rule 2) -------------------------------------------------------------------
67
-
68
- /** typeof/instanceof/in/null-equality/Array.isArray are the sanctioned ways to narrow an `unknown`. */
69
- const isNullOrUndefinedLiteral = (node) =>
70
- (node.type === "Literal" && node.value === null) || (node.type === "Identifier" && node.name === "undefined")
71
-
72
- /** The nearest enclosing function, so narrowing found in one function never covers another. */
73
- const enclosingFunction = (node) => {
74
- let current = node.parent
75
- while (current) {
76
- if (
77
- current.type === "FunctionDeclaration"
78
- || current.type === "FunctionExpression"
79
- || current.type === "ArrowFunctionExpression"
80
- ) {
81
- return current
82
- }
83
- current = current.parent
84
- }
85
- return null
86
- }
87
-
88
- /** Whether a parameter or variable is explicitly declared `: unknown` at the point it enters. */
89
- const isUnknownAnnotation = (typeAnnotation) =>
90
- !!typeAnnotation && !!typeAnnotation.typeAnnotation && typeAnnotation.typeAnnotation.type === "TSUnknownKeyword"
91
-
92
- /**
93
- * A value declared `unknown` is cast straight to a concrete type with no visible check first.
94
- *
95
- * `unknown` refuses every operation until narrowed -- except a cast, which TypeScript allows
96
- * FREELY from `unknown` to anything, no guard required. `no-double-cast` catches the pair that
97
- * launders a wrong type through `unknown`; it does not catch a single cast that starts already
98
- * `unknown` and asserts straight past the type system with nothing behind it. That gap is TYPE-1's
99
- * second half: the law asks for "unknown, narrowed exactly once, visibly", and a bare cast narrows
100
- * nothing -- it only claims to.
101
- *
102
- * Scoped to identifiers explicitly annotated `unknown` (a parameter or a `const x: unknown`), and
103
- * to narrowing found anywhere earlier in the SAME enclosing function -- not a full control-flow
104
- * proof, because this plugin carries no type information to build one. The forms it accepts as
105
- * narrowing are `typeof`, `instanceof`, `in`, a null/undefined equality check, and -- deliberately
106
- * loose -- passing the value into ANY function call, because the law names "a predicate" beside
107
- * `typeof`/`instanceof` and a custom guard is exactly that predicate; this rule cannot read what
108
- * is inside the call, so it trusts that a call taking the value is a check on it. That looseness
109
- * trades missed gaps for avoided false positives on purpose. The test lanes are exempt for the
110
- * same reason `no-double-cast` exempts them: a harness helper that hands a caller-supplied
111
- * generic type back unchecked (`nextMessage<TPayload>`) is deliberate, not a gap. Measured against
112
- * the reference backend, this rule still stays at `warn` -- the debt is real and small enough to
113
- * burn down, not yet zero.
114
- */
115
- export const noUnguardedUnknownCast = {
116
- meta: {
117
- type: "problem",
118
- docs: {
119
- description:
120
- "A value declared `unknown` is narrowed by a visible check before it is cast to a concrete type (TYPE-1, law 2).",
121
- },
122
- schema: [],
123
- messages: {
124
- unguarded:
125
- "`{{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`.",
126
- },
127
- },
128
- create(context) {
129
- // the harness lane builds generic pass-through helpers on purpose (`nextMessage<TPayload>`
130
- // handing back whatever the event bus produced as the caller's declared type) -- the same
131
- // "deliberately unchecked on purpose" property TYPE-2 already carves the test lanes out for
132
- if (isTestFile(context.filename || context.getFilename())) return {}
133
- const sourceCode = context.sourceCode || context.getSourceCode()
134
- const trackedByScope = new Map()
135
- const narrowedByScope = new Map()
136
- const scopeKey = (node) => enclosingFunction(node) || "module"
137
-
138
- const track = (name, scope) => {
139
- if (!trackedByScope.has(scope)) trackedByScope.set(scope, new Set())
140
- trackedByScope.get(scope).add(name)
141
- }
142
- const markNarrowed = (name, scope) => {
143
- if (!narrowedByScope.has(scope)) narrowedByScope.set(scope, new Set())
144
- narrowedByScope.get(scope).add(name)
145
- }
146
-
147
- const checkParams = (fnNode) => {
148
- for (const raw of fnNode.params) {
149
- const param = unwrapParam(raw)
150
- if (param.type === "Identifier" && isUnknownAnnotation(param.typeAnnotation)) {
151
- track(param.name, fnNode)
152
- }
153
- }
154
- }
155
-
156
- return {
157
- FunctionDeclaration(node) {
158
- checkParams(node)
159
- },
160
- FunctionExpression(node) {
161
- checkParams(node)
162
- },
163
- ArrowFunctionExpression(node) {
164
- checkParams(node)
165
- },
166
- VariableDeclarator(node) {
167
- if (node.id.type === "Identifier" && isUnknownAnnotation(node.id.typeAnnotation)) {
168
- track(node.id.name, scopeKey(node))
169
- }
170
- },
171
- UnaryExpression(node) {
172
- if (node.operator !== "typeof") return
173
- if (node.argument.type !== "Identifier") return
174
- markNarrowed(node.argument.name, scopeKey(node))
175
- },
176
- BinaryExpression(node) {
177
- if (node.operator === "instanceof") {
178
- if (node.left.type === "Identifier") markNarrowed(node.left.name, scopeKey(node))
179
- return
180
- }
181
- if (node.operator === "in") {
182
- if (node.right.type === "Identifier") markNarrowed(node.right.name, scopeKey(node))
183
- return
184
- }
185
- if (!["===", "!==", "==", "!="].includes(node.operator)) return
186
- if (node.left.type === "Identifier" && isNullOrUndefinedLiteral(node.right)) {
187
- markNarrowed(node.left.name, scopeKey(node))
188
- }
189
- if (node.right.type === "Identifier" && isNullOrUndefinedLiteral(node.left)) {
190
- markNarrowed(node.right.name, scopeKey(node))
191
- }
192
- },
193
- CallExpression(node) {
194
- // any predicate call that TAKES the value narrows it, the same way `typeof` does -- the
195
- // law names "a predicate" beside `typeof`/`instanceof` on purpose, and a custom guard
196
- // (`this.isPlainObject(current)`, `Array.isArray(value)`) is exactly that predicate. This
197
- // side is deliberately loose: the cost of missing a real gap here is smaller than the cost
198
- // of flagging a guard this rule cannot read the inside of.
199
- for (const arg of node.arguments) {
200
- if (arg.type === "Identifier") markNarrowed(arg.name, scopeKey(node))
201
- }
202
- },
203
- TSAsExpression(node) {
204
- if (node.expression.type !== "Identifier") return
205
- const name = node.expression.name
206
- const scope = scopeKey(node)
207
- if (!trackedByScope.get(scope) || !trackedByScope.get(scope).has(name)) return
208
- const target = node.typeAnnotation
209
- if (target && target.type === "TSUnknownKeyword") return
210
- if (narrowedByScope.get(scope) && narrowedByScope.get(scope).has(name)) return
211
- context.report({
212
- node,
213
- messageId: "unguarded",
214
- data: { name, target: sourceCode.getText(target) },
215
- })
216
- },
217
- }
218
- },
219
- }
220
-
221
- // -- TYPE-3 ----------------------------------------------------------------------------------------
222
-
223
- /** A destructured parameter's type is named, so a second caller can reference it. */
224
- export const noInlineParamType = {
225
- meta: {
226
- type: "problem",
227
- docs: { description: "A destructured parameter takes a named type, not an inline literal." },
228
- schema: [],
229
- messages: {
230
- inline:
231
- "An inline object type on a destructured parameter cannot be referenced, imported or extended - so the second caller writes it again, and when a third field arrives only one of the two copies gets it. Move it to a named interface in the module's `types/`.",
232
- },
233
- },
234
- create(context) {
235
- const check = (params) => {
236
- for (const raw of params || []) {
237
- const param = unwrapParam(raw)
238
- if (param.type !== "ObjectPattern" || !param.typeAnnotation) continue
239
- const annotation = param.typeAnnotation.typeAnnotation
240
- if (annotation && annotation.type === "TSTypeLiteral") {
241
- context.report({ node: param.typeAnnotation, messageId: "inline" })
242
- }
243
- }
244
- }
245
- return {
246
- FunctionDeclaration(node) {
247
- check(node.params)
248
- },
249
- FunctionExpression(node) {
250
- check(node.params)
251
- },
252
- ArrowFunctionExpression(node) {
253
- check(node.params)
254
- },
255
- }
256
- },
257
- }
258
-
259
- // -- TYPE-3, extended (law 4) ------------------------------------------------------------------------
260
-
261
- /**
262
- * `no-inline-param-type` holds only the destructured-parameter half of law 4: "a destructured
263
- * parameter's type has a name and a home a second caller can import from." The same sentence, read
264
- * without the word "destructured", covers every inline object type -- a positional parameter, a
265
- * property signature, a function return, a variable -- because the failure is identical in all four:
266
- * the shape cannot be referenced, imported or extended, so a second site retypes it and the two
267
- * copies drift apart in silence.
268
- *
269
- * Three things are deliberately left alone, and each is a different reason:
270
- *
271
- * - An EMPTY `{}` means "no properties", not "an unnamed shape". That is a different smell (the
272
- * fix is a stricter type, not a name) and this rule does not touch it.
273
- * - A type literal inside a `declare module` / module augmentation describes a vendor's own shape.
274
- * It cannot be pulled into a named type this repository owns without the two drifting the moment
275
- * the vendor changes theirs, so the ambient lane is out of scope the same way the test lanes are.
276
- * - A type literal that is already the right-hand side of a named type alias (`type Foo = { ... }`)
277
- * IS the fix this rule asks for. Flagging it would make the rule unsatisfiable -- there would be no
278
- * way to hold an object type by name that this rule accepted. A type literal declared elsewhere is
279
- * still checked in the ordinary way; only the exact node a type alias names is exempt.
280
- */
281
-
282
- /** A type literal with no members means "no properties" -- a different smell with a different fix. */
283
- const isEmptyTypeLiteral = (typeLiteral) => !typeLiteral.members || typeLiteral.members.length === 0
284
-
285
- /** A vendor's own declaration lives in a `declare module` / module augmentation and cannot be named separately. */
286
- const isInsideAmbientModule = (node) => {
287
- let current = node.parent
288
- while (current) {
289
- if (current.type === "TSModuleDeclaration" && current.declare) return true
290
- current = current.parent
291
- }
292
- return false
293
- }
294
-
295
- /** The non-empty inline object type at `typeAnnotation.typeAnnotation`, or null if this is out of scope. */
296
- const inlineObjectTypeOf = (typeAnnotation, scopeNode) => {
297
- if (!typeAnnotation) return null
298
- const literal = typeAnnotation.typeAnnotation
299
- if (!literal || literal.type !== "TSTypeLiteral") return null
300
- if (isEmptyTypeLiteral(literal)) return null
301
- if (isInsideAmbientModule(scopeNode)) return null
302
- return literal
303
- }
304
-
305
- export const noInlineObjectType = {
306
- meta: {
307
- type: "problem",
308
- docs: {
309
- description:
310
- "A TypeScript object type written inline takes a named type instead, in every position (TYPE-3, law 4).",
311
- },
312
- schema: [],
313
- messages: {
314
- inlineObjectType:
315
- "This inline object type ({{position}}) cannot be referenced, imported or extended, so a second site retypes it and the two copies drift apart in silence the moment a third field arrives on only one of them. Give it a name in the module's `types/` and reference that name here instead.",
316
- },
317
- },
318
- create(context) {
319
- if (isTestFile(context.filename || context.getFilename())) return {}
320
-
321
- const reportLiteral = (node, position) => {
322
- context.report({ node, messageId: "inlineObjectType", data: { position } })
323
- }
324
-
325
- const checkParams = (fnNode) => {
326
- for (const raw of fnNode.params || []) {
327
- const param = unwrapParam(raw)
328
- if (inlineObjectTypeOf(param.typeAnnotation, param)) {
329
- reportLiteral(param.typeAnnotation, "parameter")
330
- }
331
- }
332
- }
333
-
334
- const checkReturnType = (fnNode) => {
335
- if (inlineObjectTypeOf(fnNode.returnType, fnNode)) {
336
- reportLiteral(fnNode.returnType, "return type")
337
- }
338
- }
339
-
340
- return {
341
- FunctionDeclaration(node) {
342
- checkParams(node)
343
- checkReturnType(node)
344
- },
345
- FunctionExpression(node) {
346
- checkParams(node)
347
- checkReturnType(node)
348
- },
349
- ArrowFunctionExpression(node) {
350
- checkParams(node)
351
- checkReturnType(node)
352
- },
353
- TSPropertySignature(node) {
354
- if (inlineObjectTypeOf(node.typeAnnotation, node)) {
355
- reportLiteral(node.typeAnnotation, "property signature")
356
- }
357
- },
358
- VariableDeclarator(node) {
359
- if (node.id.type !== "Identifier") return
360
- if (inlineObjectTypeOf(node.id.typeAnnotation, node)) {
361
- reportLiteral(node.id.typeAnnotation, "variable")
362
- }
363
- },
364
- }
365
- },
366
- }
367
-
368
- // -- TYPE-4 ----------------------------------------------------------------------------------------
369
-
370
- /** An enum keeps its runtime object. */
371
- export const noConstEnum = {
372
- meta: {
373
- type: "problem",
374
- docs: { description: "Enums are declared plain, never `const enum`." },
375
- schema: [],
376
- messages: {
377
- constEnum:
378
- "`const enum {{name}}` is inlined at compile time and has no runtime object: it cannot be iterated, cannot be reverse-mapped, and cannot cross the isolated-modules boundary this repository compiles under. It saves a few bytes and costs a family of things that simply do not work. Declare a plain `enum`.",
379
- },
380
- },
381
- create(context) {
382
- return {
383
- TSEnumDeclaration(node) {
384
- if (!node.const) return
385
- context.report({ node, messageId: "constEnum", data: { name: node.id.name } })
386
- },
387
- }
388
- },
389
- }
390
-
391
- // -- TYPE-5 (rule 6) -- ATTEMPTED, NOT SHIPPED ------------------------------------------------------
392
- //
393
- // A rule was written here: two-or-more `is`/`has`-prefixed booleans sharing a type literal with a
394
- // field that is optional and not itself boolean. It was measured against the reference backend
395
- // before shipping, per this canon's own standard -- and both of its two real firings were wrong.
396
- // `GlobalSearchItem` (`isEnrolled` / `isFree` / `isPremium` beside an unrelated optional
397
- // `parentPath`) and `AdvertisementSeedItem` (`isHouseAd` / `isActive` beside an unrelated optional
398
- // `ctaText`) are both genuinely independent facts about one entity, not a product of states -- the
399
- // exact "transport type" and "independent booleans" exceptions `type-safety.md` already names. A
400
- // rule reporting a defect on 2 of its 2 real firings is not a rule with debt to burn down; it is
401
- // wrong, and shipping it at any level would teach a reader to distrust the machine rather than the
402
- // code. TYPE-5 stays `documented`, exactly as the law's own `Layer held` table already says: seeing
403
- // past a transport DTO to whether its flags are truly one situation needs the meaning of the code,
404
- // which is a reader's job, not a decidable question of syntax.
405
-
406
- // -- TYPE-6 (rule 7) ----------------------------------------------------------------------------------
407
-
408
- /** This law's own published rule names -- the only ones this rule polices. */
409
- const TYPE_SAFETY_RULE_NAMES = [
410
- "no-double-cast",
411
- "no-inline-param-type",
412
- "no-inline-object-type",
413
- "no-const-enum",
414
- "no-unguarded-unknown-cast",
415
- ]
416
-
417
- const DISABLE_DIRECTIVE = /^eslint-disable(?:-next-line|-line)?\b/
418
-
419
- /**
420
- * A sanctioned exit is declared at the lane, once -- not suppressed per line.
421
- *
422
- * `no-double-cast`'s test-lane exemption already lives in `isTestFile`, consulted once, so every
423
- * place the exit is in force is derivable from that one function. A per-line `eslint-disable` for
424
- * any rule this law publishes is therefore never the lane speaking -- it is either redundant (the
425
- * lane already covers the file) or an undeclared second exit (the file needed one and reached for
426
- * a comment instead of a lane). Either way `type-safety.md` names this exact failure: "a per-line
427
- * suppression standing in for a lane-wide exit".
428
- *
429
- * Deliberately scoped to only the five rule names this law publishes -- a rule that policed every
430
- * `eslint-disable` in the repository would be reaching into every other family's lane, which is
431
- * outside this file's boundary.
432
- */
433
- export const noLineSuppression = {
434
- meta: {
435
- type: "problem",
436
- docs: {
437
- description:
438
- "No inline `eslint-disable` for a type-safety rule; the lane is the only sanctioned exit (TYPE-6, law 7).",
439
- },
440
- schema: [],
441
- messages: {
442
- suppression:
443
- "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.",
444
- },
445
- },
446
- create(context) {
447
- const sourceCode = context.sourceCode || context.getSourceCode()
448
- return {
449
- "Program:exit"(node) {
450
- for (const comment of sourceCode.getAllComments()) {
451
- const text = comment.value.trim()
452
- if (!DISABLE_DIRECTIVE.test(text)) continue
453
- const named = TYPE_SAFETY_RULE_NAMES.find((name) => text.includes(`starci-be/${name}`))
454
- if (!named) continue
455
- context.report({ node: comment ?? node, messageId: "suppression", data: { name: named } })
456
- }
457
- },
458
- }
459
- },
460
- }
461
-
462
- /** The rules this law contributes to the plugin. */
463
- export const rules = {
464
- "no-double-cast": noDoubleCast,
465
- "no-inline-param-type": noInlineParamType,
466
- "no-inline-object-type": noInlineObjectType,
467
- "no-const-enum": noConstEnum,
468
- "no-unguarded-unknown-cast": noUnguardedUnknownCast,
469
- "no-line-suppression": noLineSuppression,
470
- }
471
-
472
- /**
473
- * The level this law asks for, as the plugin's own opinion.
474
- *
475
- * The two standard rules are listed beside the house ones so a consuming repository switches the
476
- * set on together -- `no-explicit-any` in particular is the one everybody already has and the one
477
- * whose absence makes the rest decorative.
478
- *
479
- * The test exit for `no-double-cast` is inside the rule rather than in a config glob, because
480
- * building a deliberately wrong value is how a spec proves a closed API refuses it, and that is a
481
- * property of the lane rather than of any repository's file layout.
482
- *
483
- * `no-inline-object-type` was published at `"off"`, not `"error"` or `"warn"` like its siblings, until
484
- * it was measured against the reference backend. The gate this package ships (`index.test.mjs`)
485
- * requires every published rule to carry a level, so `"off"` -- not absence -- was how the rule stayed
486
- * registered and switched-off at once: present for a consuming repository to opt into deliberately,
487
- * silent until the owner had the debt count in hand.
488
- *
489
- * That measurement is done: an ESLint Linter-API run, per file, across every file the reference
490
- * backend's own `eslint.config.mjs` lints (`src/**`, `apps/**`, `libs/**`, `test/**`, `tests/**` --
491
- * 4,738 files at measurement time) found zero remaining product-code fires once the three real
492
- * ones (two in `apps/mock/src/main.ts`, one in `apps/playground-rag-agent/src/rag-agent.service.ts`,
493
- * plus a fourth the same sweep turned up in `src/modules/ai/utils/extract-json-block.ts` that an
494
- * earlier pass had missed) were each given a named type. The rule's own test-file exemption held
495
- * throughout: every other hit was inside `*.spec.ts`. Zero debt is exactly the condition this
496
- * canon's own convention requires before a rule moves from `off` to `error` -- the same standard
497
- * `no-unguarded-unknown-cast` is still waiting to clear before its own `warn` becomes `error`.
498
- */
499
- export const recommended = {
500
- "starci-be/no-double-cast": "error",
501
- "starci-be/no-inline-param-type": "error",
502
- "starci-be/no-inline-object-type": "error",
503
- "starci-be/no-const-enum": "error",
504
- "starci-be/no-unguarded-unknown-cast": "warn", // no=3 -- see type-safety.mjs's doc comment
505
- "starci-be/no-line-suppression": "error",
506
- "@typescript-eslint/no-explicit-any": "error",
507
- "@typescript-eslint/array-type": ["error", {
508
- default: "generic",
509
- readonly: "generic",
510
- }],
511
- }
1
+ /**
2
+ * The rules that hold `type-safety.md`.
3
+ *
4
+ * Six house rules plus two standard ones, and the split is worth knowing. `no-explicit-any` and
5
+ * the array-type spelling already exist in the TypeScript plugin and are named in `recommended`
6
+ * rather than reimplemented -- a second implementation of a rule everybody has is a maintenance
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.
10
+ *
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 inline object type in every other position --
13
+ * property, return, variable -- that rule 4 names once it is read without the word "destructured"
14
+ * (TYPE-3, rule 4, extended; see the export below for its `recommended` level and the measurement
15
+ * that set it), the const enum
16
+ * (TYPE-4, rule 5), the unguarded cast off an `unknown` value (TYPE-1's second half, rule 2), and
17
+ * the per-line suppression standing in for a lane (TYPE-6, rule 7). Each is a way of switching the
18
+ * compiler off -- or of leaving it turned further down than the law asks -- that looks locally
19
+ * reasonable, which is exactly the class a rule is for.
20
+ *
21
+ * TYPE-5 (rule 6, the boolean-flag product) is NOT among them, and that absence was tested rather
22
+ * than assumed. See the comment where TYPE-5 would sit, below, for what was tried and why it was
23
+ * withdrawn after being measured against the reference backend.
24
+ */
25
+
26
+ import { normalizePath } from "./lib/path.mjs"
27
+
28
+ /** The spec family and the test tree may build a deliberately wrong value on purpose. */
29
+ const isTestFile = (filename) => {
30
+ const file = normalizePath(filename)
31
+ return /\.(?:spec|test|e2e-spec|int-spec|harness-spec)\.ts$/.test(file) || file.includes("/src/tests/")
32
+ }
33
+
34
+ /** A parameter property (`private readonly x: T`) wraps the parameter it declares. */
35
+ const unwrapParam = (param) => (param.type === "TSParameterProperty" ? param.parameter : param)
36
+
37
+ // -- TYPE-2 ----------------------------------------------------------------------------------------
38
+
39
+ /** A cast through `unknown` is the compiler being overruled twice. */
40
+ export const noDoubleCast = {
41
+ meta: {
42
+ type: "problem",
43
+ docs: { description: "No `x as unknown as T` outside the test lanes." },
44
+ schema: [],
45
+ messages: {
46
+ doubleCast:
47
+ "`as unknown as` is the compiler saying these types do not overlap, and being overruled twice. It is worse than `any` in one specific way: the result CLAIMS to be the target type, so everything downstream trusts it completely and the failure surfaces far from this line. Fix the type, or narrow with a guard that actually checks.",
48
+ },
49
+ },
50
+ create(context) {
51
+ if (isTestFile(context.filename || context.getFilename())) return {}
52
+ return {
53
+ TSAsExpression(node) {
54
+ // the OUTER cast of the pair: its operand is itself a cast, to `unknown`
55
+ const inner = node.expression
56
+ if (!inner || inner.type !== "TSAsExpression") return
57
+ const innerType = inner.typeAnnotation
58
+ if (!innerType || innerType.type !== "TSUnknownKeyword") return
59
+ context.report({ node, messageId: "doubleCast" })
60
+ },
61
+ }
62
+ },
63
+ }
64
+
65
+ // -- TYPE-1, second half (rule 2) -------------------------------------------------------------------
66
+
67
+ /** typeof/instanceof/in/null-equality/Array.isArray are the sanctioned ways to narrow an `unknown`. */
68
+ const isNullOrUndefinedLiteral = (node) =>
69
+ (node.type === "Literal" && node.value === null) || (node.type === "Identifier" && node.name === "undefined")
70
+
71
+ /** The nearest enclosing function, so narrowing found in one function never covers another. */
72
+ const enclosingFunction = (node) => {
73
+ let current = node.parent
74
+ while (current) {
75
+ if (
76
+ current.type === "FunctionDeclaration"
77
+ || current.type === "FunctionExpression"
78
+ || current.type === "ArrowFunctionExpression"
79
+ ) {
80
+ return current
81
+ }
82
+ current = current.parent
83
+ }
84
+ return null
85
+ }
86
+
87
+ /** Whether a parameter or variable is explicitly declared `: unknown` at the point it enters. */
88
+ const isUnknownAnnotation = (typeAnnotation) =>
89
+ !!typeAnnotation && !!typeAnnotation.typeAnnotation && typeAnnotation.typeAnnotation.type === "TSUnknownKeyword"
90
+
91
+ /**
92
+ * A value declared `unknown` is cast straight to a concrete type with no visible check first.
93
+ *
94
+ * `unknown` refuses every operation until narrowed -- except a cast, which TypeScript allows
95
+ * FREELY from `unknown` to anything, no guard required. `no-double-cast` catches the pair that
96
+ * launders a wrong type through `unknown`; it does not catch a single cast that starts already
97
+ * `unknown` and asserts straight past the type system with nothing behind it. That gap is TYPE-1's
98
+ * second half: the law asks for "unknown, narrowed exactly once, visibly", and a bare cast narrows
99
+ * nothing -- it only claims to.
100
+ *
101
+ * Scoped to identifiers explicitly annotated `unknown` (a parameter or a `const x: unknown`), and
102
+ * to narrowing found anywhere earlier in the SAME enclosing function -- not a full control-flow
103
+ * proof, because this plugin carries no type information to build one. The forms it accepts as
104
+ * narrowing are `typeof`, `instanceof`, `in`, a null/undefined equality check, and -- deliberately
105
+ * loose -- passing the value into ANY function call, because the law names "a predicate" beside
106
+ * `typeof`/`instanceof` and a custom guard is exactly that predicate; this rule cannot read what
107
+ * is inside the call, so it trusts that a call taking the value is a check on it. That looseness
108
+ * trades missed gaps for avoided false positives on purpose. The test lanes are exempt for the
109
+ * same reason `no-double-cast` exempts them: a harness helper that hands a caller-supplied
110
+ * generic type back unchecked (`nextMessage<TPayload>`) is deliberate, not a gap. Measured against
111
+ * the reference backend, this rule still stays at `warn` -- the debt is real and small enough to
112
+ * burn down, not yet zero.
113
+ */
114
+ export const noUnguardedUnknownCast = {
115
+ meta: {
116
+ type: "problem",
117
+ docs: {
118
+ description:
119
+ "A value declared `unknown` is narrowed by a visible check before it is cast to a concrete type (TYPE-1, law 2).",
120
+ },
121
+ schema: [],
122
+ messages: {
123
+ unguarded:
124
+ "`{{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`.",
125
+ },
126
+ },
127
+ create(context) {
128
+ // the harness lane builds generic pass-through helpers on purpose (`nextMessage<TPayload>`
129
+ // handing back whatever the event bus produced as the caller's declared type) -- the same
130
+ // "deliberately unchecked on purpose" property TYPE-2 already carves the test lanes out for
131
+ if (isTestFile(context.filename || context.getFilename())) return {}
132
+ const sourceCode = context.sourceCode || context.getSourceCode()
133
+ const trackedByScope = new Map()
134
+ const narrowedByScope = new Map()
135
+ const scopeKey = (node) => enclosingFunction(node) || "module"
136
+
137
+ const track = (name, scope) => {
138
+ if (!trackedByScope.has(scope)) trackedByScope.set(scope, new Set())
139
+ trackedByScope.get(scope).add(name)
140
+ }
141
+ const markNarrowed = (name, scope) => {
142
+ if (!narrowedByScope.has(scope)) narrowedByScope.set(scope, new Set())
143
+ narrowedByScope.get(scope).add(name)
144
+ }
145
+
146
+ const checkParams = (fnNode) => {
147
+ for (const raw of fnNode.params) {
148
+ const param = unwrapParam(raw)
149
+ if (param.type === "Identifier" && isUnknownAnnotation(param.typeAnnotation)) {
150
+ track(param.name, fnNode)
151
+ }
152
+ }
153
+ }
154
+
155
+ return {
156
+ FunctionDeclaration(node) {
157
+ checkParams(node)
158
+ },
159
+ FunctionExpression(node) {
160
+ checkParams(node)
161
+ },
162
+ ArrowFunctionExpression(node) {
163
+ checkParams(node)
164
+ },
165
+ VariableDeclarator(node) {
166
+ if (node.id.type === "Identifier" && isUnknownAnnotation(node.id.typeAnnotation)) {
167
+ track(node.id.name, scopeKey(node))
168
+ }
169
+ },
170
+ UnaryExpression(node) {
171
+ if (node.operator !== "typeof") return
172
+ if (node.argument.type !== "Identifier") return
173
+ markNarrowed(node.argument.name, scopeKey(node))
174
+ },
175
+ BinaryExpression(node) {
176
+ if (node.operator === "instanceof") {
177
+ if (node.left.type === "Identifier") markNarrowed(node.left.name, scopeKey(node))
178
+ return
179
+ }
180
+ if (node.operator === "in") {
181
+ if (node.right.type === "Identifier") markNarrowed(node.right.name, scopeKey(node))
182
+ return
183
+ }
184
+ if (!["===", "!==", "==", "!="].includes(node.operator)) return
185
+ if (node.left.type === "Identifier" && isNullOrUndefinedLiteral(node.right)) {
186
+ markNarrowed(node.left.name, scopeKey(node))
187
+ }
188
+ if (node.right.type === "Identifier" && isNullOrUndefinedLiteral(node.left)) {
189
+ markNarrowed(node.right.name, scopeKey(node))
190
+ }
191
+ },
192
+ CallExpression(node) {
193
+ // any predicate call that TAKES the value narrows it, the same way `typeof` does -- the
194
+ // law names "a predicate" beside `typeof`/`instanceof` on purpose, and a custom guard
195
+ // (`this.isPlainObject(current)`, `Array.isArray(value)`) is exactly that predicate. This
196
+ // side is deliberately loose: the cost of missing a real gap here is smaller than the cost
197
+ // of flagging a guard this rule cannot read the inside of.
198
+ for (const arg of node.arguments) {
199
+ if (arg.type === "Identifier") markNarrowed(arg.name, scopeKey(node))
200
+ }
201
+ },
202
+ TSAsExpression(node) {
203
+ if (node.expression.type !== "Identifier") return
204
+ const name = node.expression.name
205
+ const scope = scopeKey(node)
206
+ if (!trackedByScope.get(scope) || !trackedByScope.get(scope).has(name)) return
207
+ const target = node.typeAnnotation
208
+ if (target && target.type === "TSUnknownKeyword") return
209
+ if (narrowedByScope.get(scope) && narrowedByScope.get(scope).has(name)) return
210
+ context.report({
211
+ node,
212
+ messageId: "unguarded",
213
+ data: { name, target: sourceCode.getText(target) },
214
+ })
215
+ },
216
+ }
217
+ },
218
+ }
219
+
220
+ // -- TYPE-3 ----------------------------------------------------------------------------------------
221
+
222
+ /** A destructured parameter's type is named, so a second caller can reference it. */
223
+ export const noInlineParamType = {
224
+ meta: {
225
+ type: "problem",
226
+ docs: { description: "A destructured parameter takes a named type, not an inline literal." },
227
+ schema: [],
228
+ messages: {
229
+ inline:
230
+ "An inline object type on a destructured parameter cannot be referenced, imported or extended - so the second caller writes it again, and when a third field arrives only one of the two copies gets it. Move it to a named interface in the module's `types/`.",
231
+ },
232
+ },
233
+ create(context) {
234
+ const check = (params) => {
235
+ for (const raw of params || []) {
236
+ const param = unwrapParam(raw)
237
+ if (param.type !== "ObjectPattern" || !param.typeAnnotation) continue
238
+ const annotation = param.typeAnnotation.typeAnnotation
239
+ if (annotation && annotation.type === "TSTypeLiteral") {
240
+ context.report({ node: param.typeAnnotation, messageId: "inline" })
241
+ }
242
+ }
243
+ }
244
+ return {
245
+ FunctionDeclaration(node) {
246
+ check(node.params)
247
+ },
248
+ FunctionExpression(node) {
249
+ check(node.params)
250
+ },
251
+ ArrowFunctionExpression(node) {
252
+ check(node.params)
253
+ },
254
+ }
255
+ },
256
+ }
257
+
258
+ // -- TYPE-3, extended (law 4) ------------------------------------------------------------------------
259
+
260
+ /**
261
+ * `no-inline-param-type` holds only the destructured-parameter half of law 4: "a destructured
262
+ * parameter's type has a name and a home a second caller can import from." The same sentence, read
263
+ * without the word "destructured", covers every inline object type -- a positional parameter, a
264
+ * property signature, a function return, a variable -- because the failure is identical in all four:
265
+ * the shape cannot be referenced, imported or extended, so a second site retypes it and the two
266
+ * copies drift apart in silence.
267
+ *
268
+ * Three things are deliberately left alone, and each is a different reason:
269
+ *
270
+ * - An EMPTY `{}` means "no properties", not "an unnamed shape". That is a different smell (the
271
+ * fix is a stricter type, not a name) and this rule does not touch it.
272
+ * - A type literal inside a `declare module` / module augmentation describes a vendor's own shape.
273
+ * It cannot be pulled into a named type this repository owns without the two drifting the moment
274
+ * the vendor changes theirs, so the ambient lane is out of scope the same way the test lanes are.
275
+ * - A type literal that is already the right-hand side of a named type alias (`type Foo = { ... }`)
276
+ * IS the fix this rule asks for. Flagging it would make the rule unsatisfiable -- there would be no
277
+ * way to hold an object type by name that this rule accepted. A type literal declared elsewhere is
278
+ * still checked in the ordinary way; only the exact node a type alias names is exempt.
279
+ */
280
+
281
+ /** A type literal with no members means "no properties" -- a different smell with a different fix. */
282
+ const isEmptyTypeLiteral = (typeLiteral) => !typeLiteral.members || typeLiteral.members.length === 0
283
+
284
+ /** A vendor's own declaration lives in a `declare module` / module augmentation and cannot be named separately. */
285
+ const isInsideAmbientModule = (node) => {
286
+ let current = node.parent
287
+ while (current) {
288
+ if (current.type === "TSModuleDeclaration" && current.declare) return true
289
+ current = current.parent
290
+ }
291
+ return false
292
+ }
293
+
294
+ /** The non-empty inline object type at `typeAnnotation.typeAnnotation`, or null if this is out of scope. */
295
+ const inlineObjectTypeOf = (typeAnnotation, scopeNode) => {
296
+ if (!typeAnnotation) return null
297
+ const literal = typeAnnotation.typeAnnotation
298
+ if (!literal || literal.type !== "TSTypeLiteral") return null
299
+ if (isEmptyTypeLiteral(literal)) return null
300
+ if (isInsideAmbientModule(scopeNode)) return null
301
+ return literal
302
+ }
303
+
304
+ export const noInlineObjectType = {
305
+ meta: {
306
+ type: "problem",
307
+ docs: {
308
+ description:
309
+ "A TypeScript object type written inline takes a named type instead, in every position (TYPE-3, law 4).",
310
+ },
311
+ schema: [],
312
+ messages: {
313
+ inlineObjectType:
314
+ "This inline object type ({{position}}) cannot be referenced, imported or extended, so a second site retypes it and the two copies drift apart in silence the moment a third field arrives on only one of them. Give it a name in the module's `types/` and reference that name here instead.",
315
+ },
316
+ },
317
+ create(context) {
318
+ if (isTestFile(context.filename || context.getFilename())) return {}
319
+
320
+ const reportLiteral = (node, position) => {
321
+ context.report({ node, messageId: "inlineObjectType", data: { position } })
322
+ }
323
+
324
+ const checkParams = (fnNode) => {
325
+ for (const raw of fnNode.params || []) {
326
+ const param = unwrapParam(raw)
327
+ if (inlineObjectTypeOf(param.typeAnnotation, param)) {
328
+ reportLiteral(param.typeAnnotation, "parameter")
329
+ }
330
+ }
331
+ }
332
+
333
+ const checkReturnType = (fnNode) => {
334
+ if (inlineObjectTypeOf(fnNode.returnType, fnNode)) {
335
+ reportLiteral(fnNode.returnType, "return type")
336
+ }
337
+ }
338
+
339
+ return {
340
+ FunctionDeclaration(node) {
341
+ checkParams(node)
342
+ checkReturnType(node)
343
+ },
344
+ FunctionExpression(node) {
345
+ checkParams(node)
346
+ checkReturnType(node)
347
+ },
348
+ ArrowFunctionExpression(node) {
349
+ checkParams(node)
350
+ checkReturnType(node)
351
+ },
352
+ TSPropertySignature(node) {
353
+ if (inlineObjectTypeOf(node.typeAnnotation, node)) {
354
+ reportLiteral(node.typeAnnotation, "property signature")
355
+ }
356
+ },
357
+ VariableDeclarator(node) {
358
+ if (node.id.type !== "Identifier") return
359
+ if (inlineObjectTypeOf(node.id.typeAnnotation, node)) {
360
+ reportLiteral(node.id.typeAnnotation, "variable")
361
+ }
362
+ },
363
+ }
364
+ },
365
+ }
366
+
367
+ // -- TYPE-4 ----------------------------------------------------------------------------------------
368
+
369
+ /** An enum keeps its runtime object. */
370
+ export const noConstEnum = {
371
+ meta: {
372
+ type: "problem",
373
+ docs: { description: "Enums are declared plain, never `const enum`." },
374
+ schema: [],
375
+ messages: {
376
+ constEnum:
377
+ "`const enum {{name}}` is inlined at compile time and has no runtime object: it cannot be iterated, cannot be reverse-mapped, and cannot cross the isolated-modules boundary this repository compiles under. It saves a few bytes and costs a family of things that simply do not work. Declare a plain `enum`.",
378
+ },
379
+ },
380
+ create(context) {
381
+ return {
382
+ TSEnumDeclaration(node) {
383
+ if (!node.const) return
384
+ context.report({ node, messageId: "constEnum", data: { name: node.id.name } })
385
+ },
386
+ }
387
+ },
388
+ }
389
+
390
+ // -- TYPE-5 (rule 6) -- ATTEMPTED, NOT SHIPPED ------------------------------------------------------
391
+ //
392
+ // A rule was written here: two-or-more `is`/`has`-prefixed booleans sharing a type literal with a
393
+ // field that is optional and not itself boolean. It was measured against the reference backend
394
+ // before shipping, per this canon's own standard -- and both of its two real firings were wrong.
395
+ // `GlobalSearchItem` (`isEnrolled` / `isFree` / `isPremium` beside an unrelated optional
396
+ // `parentPath`) and `AdvertisementSeedItem` (`isHouseAd` / `isActive` beside an unrelated optional
397
+ // `ctaText`) are both genuinely independent facts about one entity, not a product of states -- the
398
+ // exact "transport type" and "independent booleans" exceptions `type-safety.md` already names. A
399
+ // rule reporting a defect on 2 of its 2 real firings is not a rule with debt to burn down; it is
400
+ // wrong, and shipping it at any level would teach a reader to distrust the machine rather than the
401
+ // code. TYPE-5 stays `documented`, exactly as the law's own `Layer held` table already says: seeing
402
+ // past a transport DTO to whether its flags are truly one situation needs the meaning of the code,
403
+ // which is a reader's job, not a decidable question of syntax.
404
+
405
+ // -- TYPE-6 (rule 7) ----------------------------------------------------------------------------------
406
+
407
+ /** This law's own published rule names -- the only ones this rule polices. */
408
+ const TYPE_SAFETY_RULE_NAMES = [
409
+ "no-double-cast",
410
+ "no-inline-param-type",
411
+ "no-inline-object-type",
412
+ "no-const-enum",
413
+ "no-unguarded-unknown-cast",
414
+ ]
415
+
416
+ const DISABLE_DIRECTIVE = /^eslint-disable(?:-next-line|-line)?\b/
417
+
418
+ /**
419
+ * A sanctioned exit is declared at the lane, once -- not suppressed per line.
420
+ *
421
+ * `no-double-cast`'s test-lane exemption already lives in `isTestFile`, consulted once, so every
422
+ * place the exit is in force is derivable from that one function. A per-line `eslint-disable` for
423
+ * any rule this law publishes is therefore never the lane speaking -- it is either redundant (the
424
+ * lane already covers the file) or an undeclared second exit (the file needed one and reached for
425
+ * a comment instead of a lane). Either way `type-safety.md` names this exact failure: "a per-line
426
+ * suppression standing in for a lane-wide exit".
427
+ *
428
+ * Deliberately scoped to only the five rule names this law publishes -- a rule that policed every
429
+ * `eslint-disable` in the repository would be reaching into every other family's lane, which is
430
+ * outside this file's boundary.
431
+ */
432
+ export const noLineSuppression = {
433
+ meta: {
434
+ type: "problem",
435
+ docs: {
436
+ description:
437
+ "No inline `eslint-disable` for a type-safety rule; the lane is the only sanctioned exit (TYPE-6, law 7).",
438
+ },
439
+ schema: [],
440
+ messages: {
441
+ suppression:
442
+ "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.",
443
+ },
444
+ },
445
+ create(context) {
446
+ const sourceCode = context.sourceCode || context.getSourceCode()
447
+ return {
448
+ "Program:exit"(node) {
449
+ for (const comment of sourceCode.getAllComments()) {
450
+ const text = comment.value.trim()
451
+ if (!DISABLE_DIRECTIVE.test(text)) continue
452
+ const named = TYPE_SAFETY_RULE_NAMES.find((name) => text.includes(`starci-be/${name}`))
453
+ if (!named) continue
454
+ context.report({ node: comment ?? node, messageId: "suppression", data: { name: named } })
455
+ }
456
+ },
457
+ }
458
+ },
459
+ }
460
+
461
+ /** The rules this law contributes to the plugin. */
462
+ export const rules = {
463
+ "no-double-cast": noDoubleCast,
464
+ "no-inline-param-type": noInlineParamType,
465
+ "no-inline-object-type": noInlineObjectType,
466
+ "no-const-enum": noConstEnum,
467
+ "no-unguarded-unknown-cast": noUnguardedUnknownCast,
468
+ "no-line-suppression": noLineSuppression,
469
+ }
470
+
471
+ /**
472
+ * The level this law asks for, as the plugin's own opinion.
473
+ *
474
+ * The two standard rules are listed beside the house ones so a consuming repository switches the
475
+ * set on together -- `no-explicit-any` in particular is the one everybody already has and the one
476
+ * whose absence makes the rest decorative.
477
+ *
478
+ * The test exit for `no-double-cast` is inside the rule rather than in a config glob, because
479
+ * building a deliberately wrong value is how a spec proves a closed API refuses it, and that is a
480
+ * property of the lane rather than of any repository's file layout.
481
+ *
482
+ * `no-inline-object-type` was published at `"off"`, not `"error"` or `"warn"` like its siblings, until
483
+ * it was measured against the reference backend. The gate this package ships (`index.test.mjs`)
484
+ * requires every published rule to carry a level, so `"off"` -- not absence -- was how the rule stayed
485
+ * registered and switched-off at once: present for a consuming repository to opt into deliberately,
486
+ * silent until the owner had the debt count in hand.
487
+ *
488
+ * That measurement is done: an ESLint Linter-API run, per file, across every file the reference
489
+ * backend's own `eslint.config.mjs` lints (`src/**`, `apps/**`, `libs/**`, `test/**`, `tests/**` --
490
+ * 4,738 files at measurement time) found zero remaining product-code fires once the three real
491
+ * ones (two in `apps/mock/src/main.ts`, one in `apps/playground-rag-agent/src/rag-agent.service.ts`,
492
+ * plus a fourth the same sweep turned up in `src/modules/ai/utils/extract-json-block.ts` that an
493
+ * earlier pass had missed) were each given a named type. The rule's own test-file exemption held
494
+ * throughout: every other hit was inside `*.spec.ts`. Zero debt is exactly the condition this
495
+ * canon's own convention requires before a rule moves from `off` to `error` -- the same standard
496
+ * `no-unguarded-unknown-cast` is still waiting to clear before its own `warn` becomes `error`.
497
+ */
498
+ export const recommended = {
499
+ "starci-be/no-double-cast": "error",
500
+ "starci-be/no-inline-param-type": "error",
501
+ "starci-be/no-inline-object-type": "error",
502
+ "starci-be/no-const-enum": "error",
503
+ "starci-be/no-unguarded-unknown-cast": "warn", // no=3 -- see type-safety.mjs's doc comment
504
+ "starci-be/no-line-suppression": "error",
505
+ "@typescript-eslint/no-explicit-any": "error",
506
+ "@typescript-eslint/array-type": ["error", {
507
+ default: "generic",
508
+ readonly: "generic",
509
+ }],
510
+ }