@starci/eslint-canon-be 1.8.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +95 -5
  2. package/README.md +41 -52
  3. package/async-discipline.mjs +2 -2
  4. package/authorization.mjs +38 -88
  5. package/builders.mjs +305 -0
  6. package/comments.mjs +19 -34
  7. package/config-owner.mjs +127 -125
  8. package/connections.mjs +254 -0
  9. package/cqrs.mjs +426 -135
  10. package/data-access.mjs +320 -391
  11. package/default-deny.mjs +14 -10
  12. package/e2e-flow.mjs +208 -180
  13. package/error-handling.mjs +254 -128
  14. package/idempotency.mjs +98 -67
  15. package/index.mjs +32 -9
  16. package/infra-owner.mjs +96 -0
  17. package/injection.mjs +602 -0
  18. package/input-bounds.mjs +166 -66
  19. package/lib/config.mjs +70 -33
  20. package/lib/db-writes.mjs +58 -0
  21. package/lib/declared.mjs +154 -0
  22. package/lib/hfs.mjs +61 -0
  23. package/lib/import-source.mjs +104 -0
  24. package/lib/persistence.mjs +160 -0
  25. package/lib/ports.mjs +138 -0
  26. package/lib/transport-slots.mjs +14 -0
  27. package/lib/types.mjs +133 -0
  28. package/lib/unit-spec.mjs +33 -0
  29. package/log-safety.mjs +94 -26
  30. package/module-layering.mjs +180 -297
  31. package/module-shape.mjs +296 -59
  32. package/naming.mjs +44 -12
  33. package/observability.mjs +57 -91
  34. package/operations.mjs +201 -0
  35. package/package.json +10 -7
  36. package/query-safety.mjs +189 -223
  37. package/resilience.mjs +126 -31
  38. package/runtime/engine/plain-object.mjs +5 -0
  39. package/runtime/engine/runtime-root.mjs +32 -0
  40. package/runtime/engine/yaml.mjs +161 -0
  41. package/runtime/knowledge/hfs/slots.yaml +1111 -0
  42. package/runtime/scripts/lib/git.mjs +53 -0
  43. package/runtime/scripts/lib/glob.mjs +23 -0
  44. package/runtime/scripts/lib/hfs-allows.mjs +57 -0
  45. package/runtime/scripts/lib/hfs-slots.mjs +729 -0
  46. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  47. package/runtime/scripts/lib/language.mjs +107 -0
  48. package/runtime/scripts/lib/path-key.mjs +17 -0
  49. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  50. package/schema-authority.mjs +273 -91
  51. package/size-budget.mjs +19 -61
  52. package/spec-no-skip.mjs +84 -0
  53. package/spec-quality.mjs +76 -35
  54. package/suppression.mjs +34 -16
  55. package/temporal.mjs +101 -36
  56. package/test-world.mjs +204 -0
  57. package/testing.mjs +99 -251
  58. package/transport.mjs +433 -142
  59. package/type-safety.mjs +50 -278
  60. package/unit-spec.mjs +452 -0
  61. package/user-copy.mjs +56 -37
  62. package/lib/slots.mjs +0 -83
package/type-safety.mjs CHANGED
@@ -1,222 +1,26 @@
1
1
  /**
2
- * The rules that hold `type-safety.md`.
2
+ * The rules that hold the type-safety law (catalog R72 `BE_TYPE_ESCAPE` and the type-shape rows).
3
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.
4
+ * WHAT THE FACTORY BORROWS, SO THIS FILE DOES NOT REIMPLEMENT IT. `starciBeConfig` turns on, at `error`, in every file
5
+ * of the repository and with no lane of exemption (a spec, a fixture and an e2e setup file get the same law):
6
+ * `@typescript-eslint/consistent-type-assertions` with `assertionStyle: "never"` (no `x as T`, no `<T>x`, hence no
7
+ * `as never`, no `as unknown as T`, no cast out of an `unknown` value; only `as const` stays),
8
+ * `@typescript-eslint/no-non-null-assertion` (no `x!`) and `@typescript-eslint/no-explicit-any`. One obligation, one
9
+ * rule: this canon writes no second copy of any of them, so the four house rules that once restated them
10
+ * (`no-never-cast`, `no-non-null-assertion`, `no-double-cast`, `no-unguarded-unknown-cast`) are deleted.
10
11
  *
11
- * What is house-written is what the standard set does not cover: the double cast (TYPE-2, rule 3),
12
- * the inline parameter type (TYPE-3, rule 4), the 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
- * 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.
12
+ * What is house-written is what the borrowed set does not cover: `Function` as a type and `eval` (R72), the inline
13
+ * parameter type and the inline object type in every other position (TYPE-3), the const enum (TYPE-4) and the
14
+ * handler return type (R75).
20
15
  *
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.
16
+ * TYPE-5 (the boolean-flag product) is NOT among them, and that absence was tested rather than assumed: a rule was
17
+ * written, measured against the reference backend and withdrawn because both of its real firings were wrong. It stays
18
+ * a reader's job, exactly as the law's own `Layer held` table says.
24
19
  */
25
20
 
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)\.ts$/.test(file) || file.includes("/src/tests/")
32
- }
33
-
34
21
  /** A parameter property (`private readonly x: T`) wraps the parameter it declares. */
35
22
  const unwrapParam = (param) => (param.type === "TSParameterProperty" ? param.parameter : param)
36
23
 
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
24
  // -- TYPE-3 ----------------------------------------------------------------------------------------
221
25
 
222
26
  /** A destructured parameter's type is named, so a second caller can reference it. */
@@ -271,7 +75,7 @@ export const noInlineParamType = {
271
75
  * fix is a stricter type, not a name) and this rule does not touch it.
272
76
  * - A type literal inside a `declare module` / module augmentation describes a vendor's own shape.
273
77
  * 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.
78
+ * the vendor changes theirs, so the ambient lane is out of scope.
275
79
  * - A type literal that is already the right-hand side of a named type alias (`type Foo = { ... }`)
276
80
  * IS the fix this rule asks for. Flagging it would make the rule unsatisfiable -- there would be no
277
81
  * way to hold an object type by name that this rule accepted. A type literal declared elsewhere is
@@ -315,8 +119,6 @@ export const noInlineObjectType = {
315
119
  },
316
120
  },
317
121
  create(context) {
318
- if (isTestFile(context.filename || context.getFilename())) return {}
319
-
320
122
  const reportLiteral = (node, position) => {
321
123
  context.report({ node, messageId: "inlineObjectType", data: { position } })
322
124
  }
@@ -389,43 +191,46 @@ export const noConstEnum = {
389
191
 
390
192
  // -- R72 -------------------------------------------------------------------------------------------
391
193
 
392
- /** `as never` asserts that a value is of no type at all, which satisfies every parameter and checks nothing. */
393
- export const noNeverCast = {
394
- meta: {
395
- type: "problem",
396
- docs: { description: "No `x as never` outside the test lanes." },
397
- schema: [],
398
- messages: {
399
- neverCast:
400
- "`as never` makes this value assignable to anything, so the compiler stops checking the call it is passed to. It hides a real mismatch between the value and the parameter. Give the value the type the parameter wants, narrow it with a guard, or fix the signature.",
401
- },
402
- },
403
- create(context) {
404
- if (isTestFile(context.filename || context.getFilename())) return {}
405
- return {
406
- TSAsExpression(node) {
407
- if (node.typeAnnotation?.type === "TSNeverKeyword") context.report({ node, messageId: "neverCast" })
408
- },
409
- }
410
- },
194
+ /** A global name (`Function`, `eval`) that no declaration of this file shadows. */
195
+ const isGlobalName = (context, node, name) => {
196
+ let scope = (context.sourceCode || context.getSourceCode()).getScope(node)
197
+ while (scope) {
198
+ const variable = scope.set.get(name)
199
+ if (variable) return variable.defs.length === 0
200
+ scope = scope.upper
201
+ }
202
+ return true
411
203
  }
412
204
 
413
- /** `x!` claims a value is present without checking. */
414
- export const noNonNullAssertion = {
205
+ /** `Function` as a type, `eval` and `new Function` run or accept code the compiler cannot see. */
206
+ export const noFunctionOrEval = {
415
207
  meta: {
416
208
  type: "problem",
417
- docs: { description: "No non-null assertion (`x!`) outside the test lanes." },
209
+ docs: { description: "No `Function` type, no `eval`, no `new Function` (the type escapes the borrowed assertion rules do not cover)." },
418
210
  schema: [],
419
211
  messages: {
420
- nonNull:
421
- "`!` claims this value is present and checks nothing; when it is not, the failure is a `TypeError` far from here. Narrow with a check that returns or throws a typed error, or change the type so absence is impossible.",
212
+ functionType:
213
+ "The `Function` type accepts any callable and returns `any`, so the compiler stops checking every call through it. Write the signature: `(input: Input) => Result`.",
214
+ evaluated:
215
+ "`{{name}}` builds code from a string, which the compiler cannot type and a reader cannot follow. Call a real function, or map a key to a function in a typed record.",
422
216
  },
423
217
  },
424
218
  create(context) {
425
- if (isTestFile(context.filename || context.getFilename())) return {}
426
219
  return {
427
- TSNonNullExpression(node) {
428
- context.report({ node, messageId: "nonNull" })
220
+ TSTypeReference(node) {
221
+ if (node.typeName.type === "Identifier" && node.typeName.name === "Function" && isGlobalName(context, node, "Function")) {
222
+ context.report({ node, messageId: "functionType" })
223
+ }
224
+ },
225
+ CallExpression(node) {
226
+ const callee = node.callee
227
+ if (callee.type !== "Identifier" || (callee.name !== "eval" && callee.name !== "Function")) return
228
+ if (isGlobalName(context, node, callee.name)) context.report({ node, messageId: "evaluated", data: { name: callee.name } })
229
+ },
230
+ NewExpression(node) {
231
+ if (node.callee.type === "Identifier" && node.callee.name === "Function" && isGlobalName(context, node, "Function")) {
232
+ context.report({ node, messageId: "evaluated", data: { name: "new Function" } })
233
+ }
429
234
  },
430
235
  }
431
236
  },
@@ -458,7 +263,6 @@ export const explicitHandlerReturnType = {
458
263
  },
459
264
  },
460
265
  create(context) {
461
- if (isTestFile(context.filename || context.getFilename())) return {}
462
266
  return {
463
267
  MethodDefinition(node) {
464
268
  if (node.kind !== "method" || node.static || !node.value.body || node.value.returnType) return
@@ -497,55 +301,23 @@ export const explicitHandlerReturnType = {
497
301
 
498
302
  /** The rules this law contributes to the plugin. */
499
303
  export const rules = {
500
- "no-double-cast": noDoubleCast,
304
+ "no-function-or-eval": noFunctionOrEval,
501
305
  "no-inline-param-type": noInlineParamType,
502
306
  "no-inline-object-type": noInlineObjectType,
503
307
  "no-const-enum": noConstEnum,
504
- "no-unguarded-unknown-cast": noUnguardedUnknownCast,
505
- "no-never-cast": noNeverCast,
506
- "no-non-null-assertion": noNonNullAssertion,
507
308
  "explicit-handler-return-type": explicitHandlerReturnType,
508
309
  }
509
310
 
510
311
  /**
511
- * The level this law asks for, as the plugin's own opinion.
512
- *
513
- * The two standard rules are listed beside the house ones so a consuming repository switches the
514
- * set on together -- `no-explicit-any` in particular is the one everybody already has and the one
515
- * whose absence makes the rest decorative.
516
- *
517
- * The test exit for `no-double-cast` is inside the rule rather than in a config glob, because
518
- * building a deliberately wrong value is how a spec proves a closed API refuses it, and that is a
519
- * property of the lane rather than of any repository's file layout.
520
- *
521
- * `no-inline-object-type` was published at `"off"`, not `"error"` or `"warn"` like its siblings, until
522
- * it was measured against the reference backend. The gate this package ships (`index.test.mjs`)
523
- * requires every published rule to carry a level, so `"off"` -- not absence -- was how the rule stayed
524
- * registered and switched-off at once: present for a consuming repository to opt into deliberately,
525
- * silent until the owner had the debt count in hand.
312
+ * The level this law asks for: every rule at `error`, in every file, specs included.
526
313
  *
527
- * That measurement is done: an ESLint Linter-API run, per file, across every file the reference
528
- * backend's own `eslint.config.mjs` lints (`src/**`, `apps/**`, `libs/**`, `test/**`, `tests/**` --
529
- * 4,738 files at measurement time) found zero remaining product-code fires once the three real
530
- * ones (two in `apps/mock/src/main.ts`, one in `apps/playground-rag-agent/src/rag-agent.service.ts`,
531
- * plus a fourth the same sweep turned up in `src/modules/ai/utils/extract-json-block.ts` that an
532
- * earlier pass had missed) were each given a named type. The rule's own test-file exemption held
533
- * throughout: every other hit was inside `*.spec.ts`. Zero debt is exactly the condition this
534
- * canon's own convention requires before a rule moves from `off` to `error` -- the same standard
535
- * `no-unguarded-unknown-cast` is still waiting to clear before its own `warn` becomes `error`.
314
+ * The borrowed type-escape rules (`consistent-type-assertions` never, `no-non-null-assertion`, `no-explicit-any`)
315
+ * are added by the factory (`lib/config.mjs`), not listed here. The array spelling rule stays with this law.
536
316
  */
537
317
  export const recommended = {
538
- "starci-be/no-double-cast": "error",
318
+ "starci-be/no-function-or-eval": "error",
539
319
  "starci-be/no-inline-param-type": "error",
540
320
  "starci-be/no-inline-object-type": "error",
541
321
  "starci-be/no-const-enum": "error",
542
- "starci-be/no-unguarded-unknown-cast": "warn", // no=3 -- see type-safety.mjs's doc comment
543
- "starci-be/no-never-cast": "error",
544
- "starci-be/no-non-null-assertion": "error",
545
322
  "starci-be/explicit-handler-return-type": "error",
546
- "@typescript-eslint/no-explicit-any": "error",
547
- "@typescript-eslint/array-type": ["error", {
548
- default: "generic",
549
- readonly: "generic",
550
- }],
551
323
  }