@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/unit-spec.mjs ADDED
@@ -0,0 +1,452 @@
1
+ /**
2
+ * The rules that hold the FORM of a unit spec (catalog R48 `BE_SPEC_QUALITY`, the unit-test standard of 2026-09-30).
3
+ *
4
+ * A unit spec is `<name>.service.spec.ts` beside `<name>.service.ts` (R47). It builds the service the way Nest builds it
5
+ * and takes every double from the one shared kit, `@starci/jest-preset`:
6
+ *
7
+ * - `spec-builds-with-testing-module`: the spec calls `Test.createTestingModule(...)` from `@nestjs/testing`, awaits
8
+ * `.compile()` and reads the subject with `.get(...)` on the resulting `TestingModule`.
9
+ * - `spec-no-new-subject`: the service under test (the class of the sibling `<name>.service.ts`) is never built with `new`.
10
+ * - `spec-module-definition-only-providers`: the module definition has a `providers` list and nothing else: no `imports` key
11
+ * and no `override*` call (`overrideProvider`, `overrideGuard`, `overrideModule`, `overrideFilter`, `overridePipe`,
12
+ * `overrideInterceptor`).
13
+ * - `spec-no-module-mock`: no `jest.mock`, `jest.doMock`, `jest.unstable_mockModule`, `jest.requireMock`, `jest.setMock`, of
14
+ * an own module or of a third-party library: a collaborator is provided through its injection token.
15
+ * - `no-return-only-generic`: no helper in a spec or in the test fixtures launders a cast into a name: a type parameter that
16
+ * appears only in the return type (`<T>(value: unknown): T`), an `unknown`/`any` parameter handed back as a concrete type,
17
+ * `JSON.parse(JSON.stringify(x))`, or `Object.assign(new X(), y)` returned as another type.
18
+ * - `spec-infra-double-from-kit`: a provider `{ provide: TOKEN, useValue: X }` takes X from the kit double the token calls
19
+ * for. The table (token pattern -> double) is `ruleParams.be.specDoubles` of the slot manifest, not this file.
20
+ *
21
+ * Paths are asked of the slot manifest (`lib/unit-spec.mjs`), a name is judged by the import that binds it, never by its spelling.
22
+ */
23
+ import { hfsOf } from "./lib/hfs.mjs"
24
+ import { walk } from "./lib/ast.mjs"
25
+ import { importOf } from "./lib/import-source.mjs"
26
+ import { isPackageType, typeOrigins } from "./lib/types.mjs"
27
+ import { baseOf, isServiceSpecFile, isUnitSpecFile, serviceNameOfSpec } from "./lib/unit-spec.mjs"
28
+
29
+ const TESTING_PACKAGE = "@nestjs/testing"
30
+
31
+ /** The last call in a chain `Test.createTestingModule(...).<a>().<b>()`: the `createTestingModule` call at its root, or null. */
32
+ const testingModuleRoot = (context, node) => {
33
+ let cursor = node
34
+ while (cursor) {
35
+ if (cursor.type === "CallExpression") {
36
+ const callee = cursor.callee
37
+ if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && callee.property.name === "createTestingModule" && callee.object.type === "Identifier") {
38
+ const found = importOf(context, callee.object)
39
+ return found && found.source === TESTING_PACKAGE && found.imported === "Test" ? cursor : null
40
+ }
41
+ cursor = callee.type === "MemberExpression" ? callee.object : null
42
+ } else if (cursor.type === "MemberExpression") cursor = cursor.object
43
+ else if (cursor.type === "AwaitExpression") cursor = cursor.argument
44
+ else return null
45
+ }
46
+ return null
47
+ }
48
+
49
+ /** True for `Test.createTestingModule(...)` where `Test` is the import of `@nestjs/testing`. */
50
+ const isCreateTestingModule = (context, node) => node.type === "CallExpression" && testingModuleRoot(context, node) === node
51
+
52
+ // -- spec-builds-with-testing-module ---------------------------------------------------------------------------
53
+
54
+ /** A service spec builds its subject through the Nest testing module. */
55
+ export const specBuildsWithTestingModule = {
56
+ meta: {
57
+ type: "problem",
58
+ docs: { description: "A `<name>.service.spec.ts` calls `Test.createTestingModule(...)` from `@nestjs/testing`, awaits `.compile()` and reads the subject with `.get(...)`." },
59
+ schema: [],
60
+ messages: {
61
+ noModule: "This service spec never calls `Test.createTestingModule(...)` from `@nestjs/testing`. Build the subject the way Nest does, so its constructor injectors are exercised: `const moduleRef = await Test.createTestingModule({ providers: [Subject, { provide: TOKEN, useValue: double }] }).compile()`.",
62
+ noCompile: "`Test.createTestingModule(...)` is never followed by `.compile()`, so no module exists to read the subject from.",
63
+ noGet: "The subject is never read with `moduleRef.get(Subject)` from the compiled `TestingModule`. Read it from the module, do not hold a second construction path.",
64
+ },
65
+ },
66
+ create(context) {
67
+ if (!isServiceSpecFile(hfsOf(context), context.filename || context.getFilename())) return {}
68
+ let created = null
69
+ let compiled = false
70
+ let read = false
71
+ return {
72
+ CallExpression(node) {
73
+ if (isCreateTestingModule(context, node)) created ??= node
74
+ const callee = node.callee
75
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return
76
+ if (callee.property.name === "compile" && (testingModuleRoot(context, callee.object) !== null || isPackageType(context, callee.object, "TestingModuleBuilder", TESTING_PACKAGE))) compiled = true
77
+ if (callee.property.name === "get" && isPackageType(context, callee.object, "TestingModule", TESTING_PACKAGE)) read = true
78
+ },
79
+ "Program:exit"(node) {
80
+ if (created === null) context.report({ node, messageId: "noModule" })
81
+ else if (!compiled) context.report({ node: created, messageId: "noCompile" })
82
+ else if (!read) context.report({ node: created, messageId: "noGet" })
83
+ },
84
+ }
85
+ },
86
+ }
87
+
88
+ // -- spec-no-new-subject ---------------------------------------------------------------------------------------
89
+
90
+ /** The last path segment of a module specifier, without an extension. */
91
+ const moduleStem = (specifier) => String(specifier).replace(/\\/g, "/").split("/").pop().replace(/\.[cm]?[jt]s$/, "")
92
+
93
+ /** The service under test is never constructed by hand. */
94
+ export const specNoNewSubject = {
95
+ meta: {
96
+ type: "problem",
97
+ docs: { description: "A service spec never builds the service under test with `new`." },
98
+ schema: [],
99
+ messages: {
100
+ construct: "`new {{name}}(...)` builds the service under test by hand, so its injectors are never exercised and a constructor change goes unnoticed. Provide it in `Test.createTestingModule({ providers: [{{name}}, ...] })` and read it with `moduleRef.get({{name}})`.",
101
+ },
102
+ },
103
+ create(context) {
104
+ const filename = context.filename || context.getFilename()
105
+ if (!isServiceSpecFile(hfsOf(context), filename)) return {}
106
+ const stem = `${serviceNameOfSpec(filename)}.service`
107
+ const locals = new Set()
108
+ const namespaces = new Set()
109
+ return {
110
+ ImportDeclaration(node) {
111
+ if (moduleStem(node.source.value) !== stem) return
112
+ for (const specifier of node.specifiers) (specifier.type === "ImportNamespaceSpecifier" ? namespaces : locals).add(specifier.local.name)
113
+ },
114
+ NewExpression(node) {
115
+ const callee = node.callee
116
+ const subject = callee.type === "Identifier" && locals.has(callee.name)
117
+ || callee.type === "MemberExpression" && !callee.computed && callee.object.type === "Identifier" && namespaces.has(callee.object.name)
118
+ if (subject) context.report({ node, messageId: "construct", data: { name: context.sourceCode.getText(callee) } })
119
+ },
120
+ }
121
+ },
122
+ }
123
+
124
+ // -- spec-module-definition-only-providers -----------------------------------------------------------------------
125
+
126
+ /** The `override*` members of `TestingModuleBuilder`. */
127
+ const OVERRIDES = new Set(["overrideProvider", "overrideGuard", "overrideModule", "overrideFilter", "overridePipe", "overrideInterceptor"])
128
+
129
+ /** The testing module lists providers and nothing else. */
130
+ export const specModuleDefinitionOnlyProviders = {
131
+ meta: {
132
+ type: "problem",
133
+ docs: { description: "A service spec's testing module has no `imports` key and no `override*` call." },
134
+ schema: [],
135
+ messages: {
136
+ imports: "`imports` pulls a real module into a unit spec, so the test runs the module's providers instead of the doubles you list. Provide exactly the service's constructor dependencies in `providers`.",
137
+ override: "`{{method}}` swaps a provider after the module was declared. A unit spec provides its double up front: `providers: [Subject, { provide: TOKEN, useValue: double }]`.",
138
+ },
139
+ },
140
+ create(context) {
141
+ if (!isServiceSpecFile(hfsOf(context), context.filename || context.getFilename())) return {}
142
+ return {
143
+ CallExpression(node) {
144
+ if (isCreateTestingModule(context, node)) {
145
+ const definition = node.arguments[0]
146
+ if (definition?.type !== "ObjectExpression") return
147
+ for (const property of definition.properties) {
148
+ const key = property.type === "Property" ? (property.key.name ?? property.key.value) : null
149
+ if (key === "imports") context.report({ node: property, messageId: "imports" })
150
+ }
151
+ return
152
+ }
153
+ const callee = node.callee
154
+ if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && OVERRIDES.has(callee.property.name)) {
155
+ context.report({ node: callee.property, messageId: "override", data: { method: callee.property.name } })
156
+ }
157
+ },
158
+ }
159
+ },
160
+ }
161
+
162
+ // -- spec-no-module-mock ---------------------------------------------------------------------------------------
163
+
164
+ /** The `jest` members that replace or fetch a module for the whole file. */
165
+ const MODULE_MOCKS = new Set(["mock", "doMock", "unstable_mockModule", "requireMock", "setMock"])
166
+
167
+ /** Whether `jest` here is the jest object: the ambient global, or the import from `@jest/globals`. */
168
+ const isJestObject = (context, node) => {
169
+ if (node.type !== "Identifier" || node.name !== "jest") return false
170
+ const imported = importOf(context, node)
171
+ if (imported) return imported.source === "@jest/globals" && imported.imported === "jest"
172
+ for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) {
173
+ const variable = scope.set.get("jest")
174
+ if (variable) return variable.defs.length === 0 || scope.type === "global" || variable.defs.every((definition) => definition.node?.parent?.declare === true)
175
+ }
176
+ return true
177
+ }
178
+
179
+ /** A unit spec replaces no module, its own or a library's. */
180
+ export const specNoModuleMock = {
181
+ meta: {
182
+ type: "problem",
183
+ docs: { description: "A unit spec never calls `jest.mock`, `jest.doMock`, `jest.unstable_mockModule` or `jest.requireMock`." },
184
+ schema: [],
185
+ messages: {
186
+ moduleMock: "`jest.{{method}}(...)` replaces a whole module, so the subject runs against a stand-in the production wiring never has. Provide a collaborator through its injection token (`{ provide: TOKEN, useValue: mock<Port>() }`); a third-party library is reached through the wrapper that owns it, and the wrapper is the double.",
187
+ },
188
+ },
189
+ create(context) {
190
+ if (!isUnitSpecFile(hfsOf(context), context.filename || context.getFilename())) return {}
191
+ return {
192
+ CallExpression(node) {
193
+ const callee = node.callee
194
+ if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier" || !MODULE_MOCKS.has(callee.property.name)) return
195
+ if (isJestObject(context, callee.object)) context.report({ node, messageId: "moduleMock", data: { method: callee.property.name } })
196
+ },
197
+ }
198
+ },
199
+ }
200
+
201
+ // -- no-return-only-generic --------------------------------------------------------------------------------------
202
+
203
+ /** Names a type reference or type query spells, anywhere below a type node. */
204
+ const typeNamesIn = (node) => {
205
+ const names = new Set()
206
+ walk(node, (child) => {
207
+ if (child.type === "TSTypeReference" && child.typeName.type === "Identifier") names.add(child.typeName.name)
208
+ })
209
+ return names
210
+ }
211
+
212
+ /** The expressions a function returns (its expression body, or each `return` of its own body). */
213
+ const returnedExpressions = (fn) => {
214
+ if (!fn.body) return []
215
+ if (fn.body.type !== "BlockStatement") return [fn.body]
216
+ const found = []
217
+ walk(fn.body, (node) => {
218
+ if (node.type === "ReturnStatement" && node.argument) found.push(node.argument)
219
+ }, { intoFunctions: false })
220
+ return found
221
+ }
222
+
223
+ /** The expression under any chain of assertions, parentheses and awaits. */
224
+ const peel = (expression) => {
225
+ let node = expression
226
+ while (node && (node.type === "TSAsExpression" || node.type === "TSTypeAssertion" || node.type === "TSNonNullExpression" || node.type === "TSSatisfiesExpression" || node.type === "AwaitExpression")) node = node.expression ?? node.argument
227
+ return node
228
+ }
229
+
230
+ /** Whether a call is `Object.assign(new X(), ...)`; gives X's name. */
231
+ const assignedClassOf = (node) => {
232
+ if (node?.type !== "CallExpression") return null
233
+ const callee = node.callee
234
+ if (callee.type !== "MemberExpression" || callee.computed || callee.object.type !== "Identifier" || callee.object.name !== "Object" || callee.property.name !== "assign") return null
235
+ const target = node.arguments[0]
236
+ return target?.type === "NewExpression" && target.callee.type === "Identifier" ? target.callee.name : null
237
+ }
238
+
239
+ /** A helper of a spec or of the fixtures is not a cast with a name. */
240
+ export const noReturnOnlyGeneric = {
241
+ meta: {
242
+ type: "problem",
243
+ docs: { description: "A spec or a test fixture has no return-only generic, no `unknown`/`any` handed back as a concrete type, no `JSON.parse(JSON.stringify(x))` and no `Object.assign(new X(), y)` returned as another type." },
244
+ schema: [],
245
+ messages: {
246
+ returnOnly: "`{{name}}` declares the type parameter `{{param}}` only in its return type, so the caller picks any type and nothing checks it: `<T>(value: unknown): T` is a cast with a name. Build the double with `mock<T>()`, `mockEntityManager()` or a `builder<T>(defaults)` from `@starci/jest-preset`, which fail to compile when the shape changes.",
247
+ launder: "`{{name}}` takes `{{kind}}` and hands it back as another type: a cast with a name. It keeps compiling when the dependency's shape changes and the double stops matching. Use a typed double from `@starci/jest-preset`.",
248
+ json: "`JSON.parse(JSON.stringify(...))` returns `any`, so it launders whatever type the receiver declares. Build the value with its own type, or clone with `structuredClone`.",
249
+ assign: "`Object.assign(new {{klass}}(), ...)` is returned as `{{returned}}`: an object of one class typed as another. Return the class it builds, or build the other type with its own builder.",
250
+ },
251
+ },
252
+ create(context) {
253
+ const hfs = hfsOf(context)
254
+ const filename = context.filename || context.getFilename()
255
+ if (!/\.(?:e2e-|integration-|contract-)?spec\.[cm]?ts$/.test(baseOf(filename)) && hfs.slotOf(filename) !== "be.tests.fixtures") return {}
256
+ const nameOf = (fn) => fn.id?.name ?? (fn.parent?.type === "VariableDeclarator" && fn.parent.id.type === "Identifier" ? fn.parent.id.name : fn.parent?.type === "Property" || fn.parent?.type === "MethodDefinition" ? (fn.parent.key.name ?? fn.parent.key.value) : "this function")
257
+ const check = (fn) => {
258
+ const name = nameOf(fn)
259
+ const declared = fn.typeParameters?.params ?? []
260
+ let generic = false
261
+ const returned = fn.returnType?.typeAnnotation
262
+ if (declared.length > 0 && returned) {
263
+ const inReturn = typeNamesIn(returned)
264
+ const inParams = new Set()
265
+ for (const param of fn.params) for (const spelled of typeNamesIn(param.typeAnnotation ?? param.left?.typeAnnotation)) inParams.add(spelled)
266
+ for (const param of declared) {
267
+ const spelled = param.name.name
268
+ // a parameter that another parameter's constraint or default mentions is still driven by the caller's arguments
269
+ const inBounds = declared.some((other) => other !== param && (typeNamesIn(other.constraint).has(spelled) || typeNamesIn(other.default).has(spelled)))
270
+ if (inReturn.has(spelled) && !inParams.has(spelled) && !inBounds) {
271
+ generic = true
272
+ context.report({ node: param, messageId: "returnOnly", data: { name, param: spelled } })
273
+ }
274
+ }
275
+ }
276
+ if (fn.body === null || fn.body === undefined) return
277
+ const loose = new Map()
278
+ for (const param of fn.params) {
279
+ const target = param.type === "AssignmentPattern" ? param.left : param
280
+ const kind = target.type === "Identifier" ? target.typeAnnotation?.typeAnnotation?.type : undefined
281
+ if (kind === "TSUnknownKeyword" || kind === "TSAnyKeyword") loose.set(target.name, kind === "TSAnyKeyword" ? "any" : "unknown")
282
+ }
283
+ const concrete = returned !== undefined && returned.type !== "TSUnknownKeyword" && returned.type !== "TSAnyKeyword"
284
+ for (const expression of returnedExpressions(fn)) {
285
+ const value = peel(expression)
286
+ if (!generic && value.type === "Identifier" && loose.has(value.name) && (concrete || value !== expression)) {
287
+ context.report({ node: expression, messageId: "launder", data: { name, kind: loose.get(value.name) } })
288
+ return
289
+ }
290
+ const klass = assignedClassOf(value)
291
+ if (klass !== null && returned?.type === "TSTypeReference" && returned.typeName.type === "Identifier" && returned.typeName.name !== klass) {
292
+ context.report({ node: expression, messageId: "assign", data: { klass, returned: returned.typeName.name } })
293
+ }
294
+ }
295
+ }
296
+ return {
297
+ FunctionDeclaration: check,
298
+ FunctionExpression: check,
299
+ ArrowFunctionExpression: check,
300
+ TSDeclareFunction: check,
301
+ CallExpression(node) {
302
+ const callee = node.callee
303
+ if (callee.type !== "MemberExpression" || callee.computed || callee.object.type !== "Identifier" || callee.object.name !== "JSON" || callee.property.name !== "parse") return
304
+ const argument = node.arguments[0]
305
+ if (argument?.type === "CallExpression" && argument.callee.type === "MemberExpression" && argument.callee.object.type === "Identifier" && argument.callee.object.name === "JSON" && argument.callee.property.name === "stringify") {
306
+ context.report({ node, messageId: "json" })
307
+ }
308
+ },
309
+ }
310
+ },
311
+ }
312
+
313
+ // -- spec-infra-double-from-kit --------------------------------------------------------------------------------
314
+
315
+ /** `EntityManager` -> `ENTITY_MANAGER`, `redis-cache` -> `REDIS_CACHE`. */
316
+ const screamingSnake = (name) => String(name).replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2").toUpperCase().replace(/[^A-Z0-9]+/g, "_")
317
+
318
+ /** The name a provider token spells: an identifier, the last member of `Tokens.CLOCK`, a string, a called token factory's name. */
319
+ const tokenName = (node) => {
320
+ if (!node) return null
321
+ if (node.type === "Identifier") return node.name
322
+ if (node.type === "Literal" && typeof node.value === "string") return node.value
323
+ if (node.type === "MemberExpression" && !node.computed && node.property.type === "Identifier") return node.property.name
324
+ if (node.type === "CallExpression") return tokenName(node.callee)
325
+ return null
326
+ }
327
+
328
+ /** The compiled table of a manifest's `specDoubles`, cached per manifest object. */
329
+ const COMPILED = new WeakMap()
330
+ const tableOf = (specDoubles) => {
331
+ let compiled = COMPILED.get(specDoubles)
332
+ if (!compiled) {
333
+ compiled = { kit: specDoubles.kit, entries: specDoubles.doubles.map((entry) => ({ ...entry, pattern: new RegExp(entry.token) })), fallback: specDoubles.fallback }
334
+ COMPILED.set(specDoubles, compiled)
335
+ }
336
+ return compiled
337
+ }
338
+
339
+ /** A value's form and the double it names: `{ form, name }`, or null when it is not a form the table knows. */
340
+ const formOf = (context, node, depth = 0) => {
341
+ const value = peel(node)
342
+ switch (value.type) {
343
+ case "CallExpression":
344
+ if (value.callee.type === "Identifier") return { form: "call", name: value.callee.name, node: value.callee }
345
+ if (value.callee.type === "CallExpression" && value.callee.callee.type === "Identifier") return { form: "curried", name: value.callee.callee.name, node: value.callee.callee }
346
+ return null
347
+ case "NewExpression":
348
+ return value.callee.type === "Identifier" ? { form: "new", name: value.callee.name, node: value.callee } : null
349
+ case "ObjectExpression":
350
+ return { form: "object" }
351
+ case "ArrayExpression":
352
+ return { form: "array" }
353
+ case "Literal":
354
+ return { form: "primitive" }
355
+ case "TemplateLiteral":
356
+ return value.expressions.length === 0 ? { form: "primitive" } : null
357
+ case "Identifier": {
358
+ if (depth >= 3) return null
359
+ for (let scope = context.sourceCode.getScope(value); scope; scope = scope.upper) {
360
+ const variable = scope.set.get(value.name)
361
+ if (!variable) continue
362
+ const definition = variable.defs[0]
363
+ if (definition?.type === "Variable" && definition.node.id.type === "Identifier" && definition.node.init && definition.parent.kind === "const") return formOf(context, definition.node.init, depth + 1)
364
+ return null
365
+ }
366
+ return null
367
+ }
368
+ default:
369
+ return null
370
+ }
371
+ }
372
+
373
+ /** The kit type each double produces, by declared name: what a parameter or a property typed so is a double of. */
374
+ const KIT_TYPES = Object.freeze({ MockEntityManager: "mockEntityManager", FakeCache: "fakeCache", FakeLock: "fakeLock", RecordingOutbox: "recordingOutbox", FakeClock: "FakeClock", FakeIds: "fakeIds", FakeTransaction: "fakeTransaction" })
375
+
376
+ /** The kit double a value's TYPE says it is (a helper parameter typed `MockEntityManager`, `tx.em` of a `fakeTransaction`), or null. */
377
+ const kitDoubleOfType = (context, node, kit) => {
378
+ const found = typeOrigins(context, node).find((origin) => Object.hasOwn(KIT_TYPES, origin.name) && origin.module === kit)
379
+ return found ? KIT_TYPES[found.name] : null
380
+ }
381
+
382
+ /** The double a token's provider value must come from. */
383
+ export const specInfraDoubleFromKit = {
384
+ meta: {
385
+ type: "problem",
386
+ docs: { description: "In a service spec a provider `{ provide: TOKEN, useValue: X }` takes X from the `@starci/jest-preset` double the token calls for (`ruleParams.be.specDoubles`)." },
387
+ schema: [],
388
+ messages: {
389
+ wrong: "`{{token}}` is provided with {{got}}, but a token of this kind takes {{want}} from `{{kit}}`. The kit double is behavioural (or, for `mock`, typed against the port), a hand-built value drifts from the thing it stands for.",
390
+ notKit: "`{{name}}` is not imported from `{{kit}}`. A double for `{{token}}` is {{want}} of the kit, never a local helper or a lookalike of the same name.",
391
+ unknown: "The value provided for `{{token}}` cannot be traced to a kit double. Bind it to a `const` initialised with {{want}} from `{{kit}}` (or write the call in place).",
392
+ },
393
+ },
394
+ create(context) {
395
+ const hfs = hfsOf(context)
396
+ if (!isServiceSpecFile(hfs, context.filename || context.getFilename())) return {}
397
+ const table = tableOf(hfs.ruleParams.specDoubles)
398
+ const describe = (entry) => {
399
+ const forms = entry.forms.map((form) => ({ call: `\`${entry.double}(...)\``, new: `\`new ${entry.double}(...)\``, curried: `\`${entry.double}(...)(...)\``, object: "a plain object literal of real values", primitive: "a real value", array: "an array" })[form])
400
+ return forms.join(" or ")
401
+ }
402
+ return {
403
+ Property(node) {
404
+ if (node.parent.type !== "ObjectExpression" || (node.key.name ?? node.key.value) !== "useValue") return
405
+ const provide = node.parent.properties.find((property) => property.type === "Property" && (property.key.name ?? property.key.value) === "provide")
406
+ const spelled = tokenName(provide?.value)
407
+ if (spelled === null) return
408
+ const normalised = screamingSnake(spelled)
409
+ const entry = table.entries.find((candidate) => candidate.pattern.test(normalised)) ?? table.fallback
410
+ const found = formOf(context, node.value)
411
+ const want = describe(entry)
412
+ const data = { token: spelled, want, kit: table.kit }
413
+ if (found === null) {
414
+ // not a call, a literal or a traceable const: the declared type of the value decides (a helper parameter, `tx.em`)
415
+ const typedAs = kitDoubleOfType(context, node.value, table.kit)
416
+ if (typedAs === null) return context.report({ node: node.value, messageId: "unknown", data })
417
+ if (typedAs !== entry.double) return context.report({ node: node.value, messageId: "wrong", data: { ...data, got: `a \`${typedAs}\` double` } })
418
+ return undefined
419
+ }
420
+ if (!entry.forms.includes(found.form)) {
421
+ const got = found.name ? `\`${found.form === "new" ? "new " : ""}${found.name}(...)\`` : `a ${found.form} value`
422
+ return context.report({ node: node.value, messageId: "wrong", data: { ...data, got } })
423
+ }
424
+ if (found.name === undefined) return undefined
425
+ const imported = importOf(context, found.node)
426
+ if (found.name !== entry.double) return context.report({ node: node.value, messageId: "wrong", data: { ...data, got: `\`${found.form === "new" ? "new " : ""}${found.name}(...)\`` } })
427
+ if (!imported || !(imported.source === table.kit || imported.source.startsWith(`${table.kit}/`)) || imported.imported !== entry.double) return context.report({ node: node.value, messageId: "notKit", data: { ...data, name: found.name } })
428
+ return undefined
429
+ },
430
+ }
431
+ },
432
+ }
433
+
434
+ /** The rules this law contributes to the plugin. */
435
+ export const rules = {
436
+ "spec-builds-with-testing-module": specBuildsWithTestingModule,
437
+ "spec-no-new-subject": specNoNewSubject,
438
+ "spec-module-definition-only-providers": specModuleDefinitionOnlyProviders,
439
+ "spec-no-module-mock": specNoModuleMock,
440
+ "no-return-only-generic": noReturnOnlyGeneric,
441
+ "spec-infra-double-from-kit": specInfraDoubleFromKit,
442
+ }
443
+
444
+ /** Every rule of this law ships at `error`. */
445
+ export const recommended = {
446
+ "starci-be/spec-builds-with-testing-module": "error",
447
+ "starci-be/spec-no-new-subject": "error",
448
+ "starci-be/spec-module-definition-only-providers": "error",
449
+ "starci-be/spec-no-module-mock": "error",
450
+ "starci-be/no-return-only-generic": "error",
451
+ "starci-be/spec-infra-double-from-kit": "error",
452
+ }
package/user-copy.mjs CHANGED
@@ -1,28 +1,31 @@
1
1
  /**
2
2
  * The rule that holds the messages catalog (catalog R78 `BE_USER_COPY_LITERAL`).
3
3
  *
4
- * Text a user reads - an exception's message, a notification's subject or body, a response's
5
- * `message` or `description` - lives in a per-capability `messages` catalog (Vietnamese and
6
- * English), reached through the typed `platform/i18n` `MessageCatalog` port by a key with named
7
- * interpolation. A literal in source cannot be translated, cannot be found by the key a test
8
- * asserts on, and drifts the moment one call site is edited and the others are not.
4
+ * Text a user reads - an exception's message, a notification's subject or body, a response's `message` or
5
+ * `description` - lives in a per-owner `messages` catalog (Vietnamese and English), reached through the typed
6
+ * `platform/i18n` `MessageCatalog` port by a key with named interpolation. A literal in source cannot be translated,
7
+ * cannot be found by the key a test asserts on, and drifts the moment one call site is edited and the others are not.
9
8
  *
10
- * - `user-copy-through-catalog` refuses a literal string in the three places copy is written by
11
- * hand: the message argument of a `*Error`/`*Exception` constructor, a `subject`/`title`/
12
- * `body`/`text`/`message` property of an object passed to `notify`/`send`/`publish`, and a
13
- * `message`/`description`/`title` property of an object literal built in a `transport/` file.
9
+ * - `user-copy-through-catalog` refuses a literal string in the three places copy is written by hand:
10
+ * 1. the message argument of a constructed error - an instance of a class that extends the language `Error`
11
+ * (the constructed TYPE decides, not a `*Error` name); the built-in `Error` family itself is left for
12
+ * internal invariants;
13
+ * 2. a `subject`/`title`/`body`/`text`/`message` property of an object handed to an outbound port - a call on a
14
+ * receiver whose type is declared by an `integrations` owner or by `platform/messaging` (the receiver's type
15
+ * decides, not a method name such as `send`);
16
+ * 3. a `message`/`description`/`title` property of an object literal built in a transport slot.
14
17
  *
15
- * A short, code-shaped literal (`UPPER_SNAKE`, or a single bare word with no space or punctuation -
16
- * `"PLAN_NOT_FOUND"`, `"pending"`) is a key or an enum value, not copy, and is left alone; so is a
17
- * template literal, which this reads one file's syntax for and cannot split into its static and
18
- * interpolated halves without guessing. `no-non-ascii-source` already refuses Vietnamese text
19
- * anywhere; this rule is the English half, scoped to the three places copy is written.
18
+ * A short, code-shaped literal (`UPPER_SNAKE`, or a single bare word with no space or punctuation - `"PLAN_NOT_FOUND"`,
19
+ * `"pending"`) is a key or an enum value, not copy, and is left alone; so is a template literal, which this reads one
20
+ * file's syntax for and cannot split into its static and interpolated halves without guessing. Specs get the same
21
+ * law: fixture text is a `messages` catalog key or lives in a fixture.
20
22
  */
21
23
  import { keyName, staticText } from "./lib/ast.mjs"
22
- import { isDeclarationFile, isTestLane, normalizePath } from "./lib/path.mjs"
24
+ import { hfsOf } from "./lib/hfs.mjs"
25
+ import { ownerNameOf } from "./lib/ports.mjs"
26
+ import { typeOrigins, typed } from "./lib/types.mjs"
27
+ import { isDeclarationFile } from "./lib/path.mjs"
23
28
 
24
- const BUILTIN_ERRORS = new Set(["Error", "TypeError", "RangeError", "SyntaxError", "EvalError", "URIError", "ReferenceError", "AggregateError"])
25
- const NOTIFY_METHODS = new Set(["notify", "send", "publish"])
26
29
  const NOTIFY_TEXT_PROPS = new Set(["subject", "title", "body", "text", "message"])
27
30
  const RESPONSE_TEXT_PROPS = new Set(["message", "description", "title"])
28
31
 
@@ -35,12 +38,23 @@ const looksLikeCopy = (text) => {
35
38
  return true
36
39
  }
37
40
 
38
- const isNotifyCall = (node) =>
39
- node?.type === "CallExpression"
40
- && node.callee.type === "MemberExpression"
41
- && !node.callee.computed
42
- && node.callee.property.type === "Identifier"
43
- && NOTIFY_METHODS.has(node.callee.property.name)
41
+ /** Whether a TypeScript type is a class the repository or a package declares that extends the language `Error`. */
42
+ const isCustomError = (program, checker, type) => {
43
+ const declaredByLanguage = (candidate) => (candidate.getSymbol()?.getDeclarations() ?? []).some((declaration) => program.isSourceFileDefaultLibrary(declaration.getSourceFile()))
44
+ if (declaredByLanguage(type)) return false
45
+ const seen = new Set()
46
+ const extendsError = (candidate) => {
47
+ if (seen.has(candidate)) return false
48
+ seen.add(candidate)
49
+ if (!candidate.isClassOrInterface()) return false
50
+ return checker.getBaseTypes(candidate).some((base) => (declaredByLanguage(base) && base.getSymbol()?.name === "Error") || extendsError(base))
51
+ }
52
+ return extendsError(type)
53
+ }
54
+
55
+ /** Whether a receiver's type is declared by an `integrations` owner or by `platform/messaging`: an outbound port. */
56
+ const isOutboundPort = (context, hfs, node) =>
57
+ typeOrigins(context, node).some((origin) => origin.module === null && (hfs.tierOf(origin.file) === "integrations" || (hfs.tierOf(origin.file) === "platform" && ownerNameOf(hfs, origin.file) === "messaging")))
44
58
 
45
59
  /** User-facing text is looked up in the catalog by key, never written where it is used. */
46
60
  export const userCopyThroughCatalog = {
@@ -49,27 +63,34 @@ export const userCopyThroughCatalog = {
49
63
  docs: { description: "A literal exception message, notification text or response copy comes from the messages catalog, not from source." },
50
64
  schema: [],
51
65
  messages: {
52
- exception: "`{{name}}`'s message is a literal string. A user-facing exception message comes from the per-capability messages catalog (`messages.get('<key>', params)`) through the typed `MessageCatalog` port, in Vietnamese and English, not written in source.",
53
- notification: "`{{property}}` of this {{call}} call is a literal string. Notification copy comes from the messages catalog through the `MessageCatalog` port, not written at the call site.",
66
+ exception: "`{{name}}`'s message is a literal string. A user-facing exception message comes from the per-owner messages catalog (`messages.get('<key>', params)`) through the typed `MessageCatalog` port, in Vietnamese and English, not written in source.",
67
+ notification: "`{{property}}` of this call to an outbound port is a literal string. Notification copy comes from the messages catalog through the `MessageCatalog` port, not written at the call site.",
54
68
  response: "`{{property}}` of this response is a literal string. Response copy comes from the messages catalog through the `MessageCatalog` port, not written in source.",
55
69
  },
56
70
  },
57
71
  create(context) {
58
- const filename = normalizePath(context.filename || context.getFilename())
59
- if (isDeclarationFile(filename) || isTestLane(filename)) return {}
60
- const inTransport = filename.includes("/transport/")
72
+ const filename = context.filename || context.getFilename()
73
+ if (isDeclarationFile(filename)) return {}
74
+ const hfs = hfsOf(context)
75
+ const slot = hfs.slotOf(filename) ?? ""
76
+ const inTransport = slot.startsWith("be.transport.") || slot === "be.feature.transport.cli"
77
+ const { program, checker, toTs } = typed(context)
78
+ const isOutboundCall = (node) =>
79
+ node?.type === "CallExpression" && node.callee.type === "MemberExpression" && !node.callee.computed && isOutboundPort(context, hfs, node.callee.object)
61
80
  return {
62
81
  NewExpression(node) {
63
- const name = node.callee.type === "Identifier" ? node.callee.name : null
64
- if (!name || BUILTIN_ERRORS.has(name) || !/(?:Error|Exception)$/.test(name)) return
65
82
  const first = node.arguments[0]
66
83
  if (!first) return
84
+ const tsNode = toTs(node)
85
+ if (!tsNode || !isCustomError(program, checker, checker.getTypeAtLocation(tsNode))) return
67
86
  const text = staticText(first)
68
- if (text !== null && looksLikeCopy(text)) context.report({ node: first, messageId: "exception", data: { name } })
87
+ if (text !== null && looksLikeCopy(text)) {
88
+ const name = node.callee.type === "Identifier" ? node.callee.name : "the error"
89
+ context.report({ node: first, messageId: "exception", data: { name } })
90
+ }
69
91
  },
70
92
  CallExpression(node) {
71
- if (!isNotifyCall(node)) return
72
- const callName = node.callee.property.name
93
+ if (!isOutboundCall(node)) return
73
94
  const objectArg = node.arguments.find((argument) => argument.type === "ObjectExpression")
74
95
  if (!objectArg) return
75
96
  for (const property of objectArg.properties) {
@@ -77,9 +98,7 @@ export const userCopyThroughCatalog = {
77
98
  const key = keyName(property.key)
78
99
  if (!key || !NOTIFY_TEXT_PROPS.has(key)) continue
79
100
  const text = staticText(property.value)
80
- if (text !== null && looksLikeCopy(text)) {
81
- context.report({ node: property.value, messageId: "notification", data: { property: key, call: callName } })
82
- }
101
+ if (text !== null && looksLikeCopy(text)) context.report({ node: property.value, messageId: "notification", data: { property: key } })
83
102
  }
84
103
  },
85
104
  Property(node) {
@@ -88,7 +107,7 @@ export const userCopyThroughCatalog = {
88
107
  if (!key || !RESPONSE_TEXT_PROPS.has(key)) return
89
108
  const text = staticText(node.value)
90
109
  if (text === null || !looksLikeCopy(text)) return
91
- if (isNotifyCall(node.parent?.parent)) return // already judged as notification copy
110
+ if (isOutboundCall(node.parent?.parent)) return // already judged as notification copy
92
111
  context.report({ node: node.value, messageId: "response", data: { property: key } })
93
112
  },
94
113
  }