@starci/eslint-canon-be 3.3.0 → 3.4.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 (80) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +2 -2
  3. package/app-composition.mjs +348 -3
  4. package/authorization.mjs +68 -2
  5. package/builders.mjs +2 -2
  6. package/config-owner.mjs +44 -15
  7. package/default-deny.mjs +80 -9
  8. package/edge-hardening.mjs +139 -0
  9. package/feature-thin.mjs +78 -2
  10. package/function-budget.mjs +49 -0
  11. package/health-ready.mjs +539 -0
  12. package/index.mjs +37 -0
  13. package/infra-owner.mjs +3 -0
  14. package/jwt-alg.mjs +45 -0
  15. package/lib/subject-values.mjs +55 -0
  16. package/lib/unit-spec.mjs +0 -3
  17. package/lib/webhook-handoff-inbox.mjs +260 -0
  18. package/lib/webhook-handoff-intake.mjs +140 -0
  19. package/lib/why.mjs +13 -0
  20. package/module-shape.mjs +147 -51
  21. package/operations.mjs +165 -1
  22. package/package.json +1 -1
  23. package/realtime.mjs +59 -22
  24. package/resilience.mjs +45 -14
  25. package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
  26. package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
  27. package/runtime/knowledge/hfs/rules.yaml +3401 -152
  28. package/runtime/knowledge/hfs/slots.yaml +344 -62
  29. package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
  30. package/runtime/knowledge/sonar-gate.yaml +1 -1
  31. package/runtime/modules/models/runtimes.yaml +1 -1
  32. package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
  33. package/runtime/scripts/api/git/lib.mjs +3 -0
  34. package/runtime/scripts/api/git/merge-base.mjs +9 -0
  35. package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
  36. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
  37. package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
  38. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
  39. package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
  40. package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
  41. package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
  42. package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
  43. package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
  44. package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
  45. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
  46. package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
  47. package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
  48. package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
  49. package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
  50. package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
  51. package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
  52. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
  53. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
  54. package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
  55. package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
  56. package/runtime/scripts/hfs/architecture/tiers.mjs +3 -3
  57. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
  58. package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
  59. package/runtime/scripts/hfs/edition-slots.mjs +2 -2
  60. package/runtime/scripts/hfs/manifest-shape.mjs +67 -7
  61. package/runtime/scripts/hfs/project-rule.mjs +3 -1
  62. package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
  63. package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
  64. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  65. package/runtime/scripts/hfs/slot-imports.mjs +18 -5
  66. package/runtime/scripts/hfs/slots.mjs +1 -0
  67. package/runtime/scripts/lib/env.mjs +20 -0
  68. package/runtime/scripts/lib/event-contract.mjs +107 -0
  69. package/runtime/scripts/lib/json-clone.mjs +61 -0
  70. package/runtime/scripts/lib/list.mjs +4 -0
  71. package/runtime/scripts/lib/secret-patterns.mjs +60 -5
  72. package/runtime/scripts/lib/stack-services.mjs +1 -1
  73. package/runtime/scripts/lib/ts-analysis.mjs +5 -2
  74. package/runtime/scripts/lib/ts-ast.mjs +27 -2
  75. package/service-isolation.mjs +3 -0
  76. package/size-budget.mjs +9 -5
  77. package/third-party-owner.mjs +149 -0
  78. package/transport.mjs +11 -29
  79. package/unit-spec.mjs +270 -84
  80. package/webhooks.mjs +169 -0
package/unit-spec.mjs CHANGED
@@ -1,12 +1,12 @@
1
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).
2
+ * The rules that hold the FORM of a unit spec (R48 `BE_SPEC_QUALITY`, extended by R281 `BE_SPEC_FORM_ALL_ROLES`).
3
3
  *
4
- * A unit spec is `<name>.service.spec.ts` beside `<name>.service.ts` (R47). It builds the service the way Nest builds it
4
+ * A unit spec is `<name>.<role>.spec.ts` beside its logic-role subject (R47). It builds the subject the way Nest builds it
5
5
  * and takes every double from the one shared kit, `@starci/jest-preset`:
6
6
  *
7
7
  * - `spec-builds-with-testing-module`: the spec calls `Test.createTestingModule(...)` from `@nestjs/testing`, awaits
8
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`.
9
+ * - `spec-no-new-subject`: the subject (the class of the sibling `<name>.<role>.ts`) is never built with `new`.
10
10
  * - `spec-module-definition-only-providers`: the module definition has a `providers` list and nothing else: no `imports` key
11
11
  * and no `override*` call (`overrideProvider`, `overrideGuard`, `overrideModule`, `overrideFilter`, `overridePipe`,
12
12
  * `overrideInterceptor`).
@@ -20,27 +20,37 @@
20
20
  * - `spec-exact-values`: no `expect.any(String)`, `expect.any(Number)` or `expect.any(Date)` (`expect` being the jest global
21
21
  * or the `@jest/globals` import): ids and clocks come from the kit's deterministic doubles (`fakeIds()`, `FakeClock`), so a
22
22
  * unit spec asserts the exact value.
23
+ * - `spec-behavior-assertion`: every `it`/`test` asserts a result or effect; a spec with only presence/truthiness
24
+ * assertions must assert an exact result or effect.
23
25
  *
24
26
  * 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.
25
27
  */
26
28
  import { hfsOf } from "./lib/hfs.mjs"
27
- import { walk } from "./lib/ast.mjs"
28
- import { importOf } from "./lib/import-source.mjs"
29
+ import { keyName, walk } from "./lib/ast.mjs"
30
+ import { importOf, isImportedFrom } from "./lib/import-source.mjs"
29
31
  import { isPackageType, typeOrigins } from "./lib/types.mjs"
30
- import { baseOf, isServiceSpecFile, isUnitSpecFile, serviceNameOfSpec } from "./lib/unit-spec.mjs"
32
+ import { baseOf, isServiceSpecFile, isUnitSpecFile, unitRoleOfSpec } from "./lib/unit-spec.mjs"
33
+ import { isRealSubjectValue, valueObjectsOfSibling } from "./lib/subject-values.mjs"
31
34
 
32
35
  const TESTING_PACKAGE = "@nestjs/testing"
33
36
 
37
+ /** Existing service specs and every other unit-tested logic role declared by the manifest. */
38
+ const isFormSpecFile = (hfs, filename) => isServiceSpecFile(hfs, filename) || unitRoleOfSpec(hfs, filename) !== null
39
+
40
+ /** Whether this callee creates a Nest testing module through its actual import. */
41
+ const isTestingFactory = (context, callee) => {
42
+ if (callee.type !== "MemberExpression" || callee.computed || keyName(callee.property) !== "createTestingModule") return false
43
+ const found = importOf(context, callee.object)
44
+ return found?.source === TESTING_PACKAGE && found.imported === "Test"
45
+ }
46
+
34
47
  /** The last call in a chain `Test.createTestingModule(...).<a>().<b>()`: the `createTestingModule` call at its root, or null. */
35
48
  const testingModuleRoot = (context, node) => {
36
49
  let cursor = node
37
50
  while (cursor) {
38
51
  if (cursor.type === "CallExpression") {
39
52
  const callee = cursor.callee
40
- if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && callee.property.name === "createTestingModule" && callee.object.type === "Identifier") {
41
- const found = importOf(context, callee.object)
42
- return found && found.source === TESTING_PACKAGE && found.imported === "Test" ? cursor : null
43
- }
53
+ if (isTestingFactory(context, callee)) return cursor
44
54
  cursor = callee.type === "MemberExpression" ? callee.object : null
45
55
  } else if (cursor.type === "MemberExpression") cursor = cursor.object
46
56
  else if (cursor.type === "AwaitExpression") cursor = cursor.argument
@@ -54,20 +64,22 @@ const isCreateTestingModule = (context, node) => node.type === "CallExpression"
54
64
 
55
65
  // -- spec-builds-with-testing-module ---------------------------------------------------------------------------
56
66
 
57
- /** A service spec builds its subject through the Nest testing module. */
67
+ /** A logic-role spec builds its subject through the Nest testing module. */
58
68
  export const specBuildsWithTestingModule = {
59
69
  meta: {
60
70
  type: "problem",
61
- docs: { description: "A `<name>.service.spec.ts` calls `Test.createTestingModule(...)` from `@nestjs/testing`, awaits `.compile()` and reads the subject with `.get(...)`." },
71
+ docs: { description: "A logic-role unit spec calls `Test.createTestingModule(...)` from `@nestjs/testing`, awaits `.compile()` and reads the subject with `.get(...)`." },
62
72
  schema: [],
63
73
  messages: {
64
- 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()`.",
65
- noCompile: "`Test.createTestingModule(...)` is never followed by `.compile()`, so no module exists to read the subject from.",
66
- 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.",
74
+ noModule: "[BE_SPEC_FORM_ALL_ROLES] This logic-role 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()`.",
75
+ noCompile: "[BE_SPEC_FORM_ALL_ROLES] `Test.createTestingModule(...)` is never followed by `.compile()`, so no module exists to read the subject from.",
76
+ noGet: "[BE_SPEC_FORM_ALL_ROLES] 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.",
67
77
  },
68
78
  },
69
79
  create(context) {
70
- if (!isServiceSpecFile(hfsOf(context), context.filename || context.getFilename())) return {}
80
+ if (!isFormSpecFile(hfsOf(context), context.filename || context.getFilename())) return {}
81
+ // a value-object subject (undecorated, no constructor injection token) is built with `new`: Nest never constructs it
82
+ const valueObjectSpec = valueObjectsOfSibling(context).length > 0
71
83
  let created = null
72
84
  let compiled = false
73
85
  let read = false
@@ -80,6 +92,7 @@ export const specBuildsWithTestingModule = {
80
92
  if (callee.property.name === "get" && isPackageType(context, callee.object, "TestingModule", TESTING_PACKAGE)) read = true
81
93
  },
82
94
  "Program:exit"(node) {
95
+ if (created === null && valueObjectSpec) return
83
96
  if (created === null) context.report({ node, messageId: "noModule" })
84
97
  else if (!compiled) context.report({ node: created, messageId: "noCompile" })
85
98
  else if (!read) context.report({ node: created, messageId: "noGet" })
@@ -93,26 +106,30 @@ export const specBuildsWithTestingModule = {
93
106
  /** The last path segment of a module specifier, without an extension. */
94
107
  const moduleStem = (specifier) => String(specifier).replace(/\\/g, "/").split("/").pop().replace(/\.[cm]?[jt]s$/, "")
95
108
 
96
- /** The service under test is never constructed by hand. */
109
+ /** The logic-role subject is never constructed by hand. */
97
110
  export const specNoNewSubject = {
98
111
  meta: {
99
112
  type: "problem",
100
- docs: { description: "A service spec never builds the service under test with `new`." },
113
+ docs: { description: "A logic-role unit spec never builds its subject with `new`." },
101
114
  schema: [],
102
115
  messages: {
103
- 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}})`.",
116
+ construct: "[BE_SPEC_FORM_ALL_ROLES] `new {{name}}(...)` builds the subject 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}})`.",
104
117
  },
105
118
  },
106
119
  create(context) {
107
120
  const filename = context.filename || context.getFilename()
108
- if (!isServiceSpecFile(hfsOf(context), filename)) return {}
109
- const stem = `${serviceNameOfSpec(filename)}.service`
121
+ if (!isFormSpecFile(hfsOf(context), filename)) return {}
122
+ const stem = baseOf(filename).replace(/\.spec\.ts$/, "")
110
123
  const locals = new Set()
111
124
  const namespaces = new Set()
125
+ const valueObjects = new Set(valueObjectsOfSibling(context))
112
126
  return {
113
127
  ImportDeclaration(node) {
114
128
  if (moduleStem(node.source.value) !== stem) return
115
- for (const specifier of node.specifiers) (specifier.type === "ImportNamespaceSpecifier" ? namespaces : locals).add(specifier.local.name)
129
+ for (const specifier of node.specifiers) {
130
+ if (specifier.type === "ImportSpecifier" && valueObjects.has(specifier.imported.name ?? specifier.imported.value)) continue
131
+ (specifier.type === "ImportNamespaceSpecifier" ? namespaces : locals).add(specifier.local.name)
132
+ }
116
133
  },
117
134
  NewExpression(node) {
118
135
  const callee = node.callee
@@ -133,15 +150,15 @@ const OVERRIDES = new Set(["overrideProvider", "overrideGuard", "overrideModule"
133
150
  export const specModuleDefinitionOnlyProviders = {
134
151
  meta: {
135
152
  type: "problem",
136
- docs: { description: "A service spec's testing module has no `imports` key and no `override*` call." },
153
+ docs: { description: "A logic-role unit spec's testing module has no `imports` key and no `override*` call." },
137
154
  schema: [],
138
155
  messages: {
139
- 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`.",
140
- override: "`{{method}}` swaps a provider after the module was declared. A unit spec provides its double up front: `providers: [Subject, { provide: TOKEN, useValue: double }]`.",
156
+ imports: "[BE_SPEC_FORM_ALL_ROLES] `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 subject's constructor dependencies in `providers`.",
157
+ override: "[BE_SPEC_FORM_ALL_ROLES] `{{method}}` swaps a provider after the module was declared. A unit spec provides its double up front: `providers: [Subject, { provide: TOKEN, useValue: double }]`.",
141
158
  },
142
159
  },
143
160
  create(context) {
144
- if (!isServiceSpecFile(hfsOf(context), context.filename || context.getFilename())) return {}
161
+ if (!isFormSpecFile(hfsOf(context), context.filename || context.getFilename())) return {}
145
162
  return {
146
163
  CallExpression(node) {
147
164
  if (isCreateTestingModule(context, node)) {
@@ -186,7 +203,7 @@ export const specNoModuleMock = {
186
203
  docs: { description: "A unit spec never calls `jest.mock`, `jest.doMock`, `jest.unstable_mockModule` or `jest.requireMock`." },
187
204
  schema: [],
188
205
  messages: {
189
- 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.",
206
+ moduleMock: "[BE_SPEC_FORM_ALL_ROLES] `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.",
190
207
  },
191
208
  },
192
209
  create(context) {
@@ -239,6 +256,71 @@ const assignedClassOf = (node) => {
239
256
  return target?.type === "NewExpression" && target.callee.type === "Identifier" ? target.callee.name : null
240
257
  }
241
258
 
259
+ /** The declared name of a function or the property that holds it. */
260
+ const functionNameOf = (fn) => {
261
+ if (fn.id?.name) return fn.id.name
262
+ const parent = fn.parent
263
+ if (parent?.type === "VariableDeclarator" && parent.id.type === "Identifier") return parent.id.name
264
+ if (parent?.type === "Property" || parent?.type === "MethodDefinition") return parent.key.name ?? parent.key.value
265
+ return "this function"
266
+ }
267
+
268
+ /** Names that the parameter types mention. */
269
+ const parameterTypeNames = (fn) => {
270
+ const names = new Set()
271
+ for (const param of fn.params) {
272
+ for (const spelled of typeNamesIn(param.typeAnnotation ?? param.left?.typeAnnotation)) names.add(spelled)
273
+ }
274
+ return names
275
+ }
276
+
277
+ /** Report type parameters chosen only by the caller's expected return type. */
278
+ const reportReturnOnlyParameters = (context, fn, name) => {
279
+ const declared = fn.typeParameters?.params ?? []
280
+ const returned = fn.returnType?.typeAnnotation
281
+ if (declared.length === 0 || !returned) return false
282
+ const inReturn = typeNamesIn(returned)
283
+ const inParams = parameterTypeNames(fn)
284
+ let generic = false
285
+ for (const param of declared) {
286
+ const spelled = param.name.name
287
+ // A parameter mentioned by another parameter's bound is driven by the caller's arguments.
288
+ const inBounds = declared.some((other) => other !== param && (typeNamesIn(other.constraint).has(spelled) || typeNamesIn(other.default).has(spelled)))
289
+ if (inReturn.has(spelled) && !inParams.has(spelled) && !inBounds) {
290
+ generic = true
291
+ context.report({ node: param, messageId: "returnOnly", data: { name, param: spelled } })
292
+ }
293
+ }
294
+ return generic
295
+ }
296
+
297
+ /** Untyped parameters handed back by a spec helper. */
298
+ const looseParameters = (fn) => {
299
+ const loose = new Map()
300
+ for (const param of fn.params) {
301
+ const target = param.type === "AssignmentPattern" ? param.left : param
302
+ const kind = target.type === "Identifier" ? target.typeAnnotation?.typeAnnotation?.type : undefined
303
+ if (kind === "TSUnknownKeyword" || kind === "TSAnyKeyword") loose.set(target.name, kind === "TSAnyKeyword" ? "any" : "unknown")
304
+ }
305
+ return loose
306
+ }
307
+
308
+ /** Report a helper return that launders an untyped value or a different class. */
309
+ const reportHelperReturn = (context, expression, { name, generic, loose, returned }) => {
310
+ const value = peel(expression)
311
+ const returnedType = returned?.type
312
+ const concrete = returnedType !== undefined && returnedType !== "TSUnknownKeyword" && returnedType !== "TSAnyKeyword"
313
+ if (!generic && value.type === "Identifier" && loose.has(value.name) && (concrete || value !== expression)) {
314
+ context.report({ node: expression, messageId: "launder", data: { name, kind: loose.get(value.name) } })
315
+ return true
316
+ }
317
+ const klass = assignedClassOf(value)
318
+ if (klass !== null && returned?.type === "TSTypeReference" && returned.typeName.type === "Identifier" && returned.typeName.name !== klass) {
319
+ context.report({ node: expression, messageId: "assign", data: { klass, returned: returned.typeName.name } })
320
+ }
321
+ return false
322
+ }
323
+
242
324
  /** A helper of a spec or of the fixtures is not a cast with a name. */
243
325
  export const noReturnOnlyGeneric = {
244
326
  meta: {
@@ -256,44 +338,14 @@ export const noReturnOnlyGeneric = {
256
338
  const hfs = hfsOf(context)
257
339
  const filename = context.filename || context.getFilename()
258
340
  if (!/\.(?:e2e-|integration-|contract-)?spec\.[cm]?ts$/.test(baseOf(filename)) && hfs.slotOf(filename) !== "be.tests.fixtures") return {}
259
- 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")
260
341
  const check = (fn) => {
261
- const name = nameOf(fn)
262
- const declared = fn.typeParameters?.params ?? []
263
- let generic = false
342
+ const name = functionNameOf(fn)
343
+ const generic = reportReturnOnlyParameters(context, fn, name)
264
344
  const returned = fn.returnType?.typeAnnotation
265
- if (declared.length > 0 && returned) {
266
- const inReturn = typeNamesIn(returned)
267
- const inParams = new Set()
268
- for (const param of fn.params) for (const spelled of typeNamesIn(param.typeAnnotation ?? param.left?.typeAnnotation)) inParams.add(spelled)
269
- for (const param of declared) {
270
- const spelled = param.name.name
271
- // a parameter that another parameter's constraint or default mentions is still driven by the caller's arguments
272
- const inBounds = declared.some((other) => other !== param && (typeNamesIn(other.constraint).has(spelled) || typeNamesIn(other.default).has(spelled)))
273
- if (inReturn.has(spelled) && !inParams.has(spelled) && !inBounds) {
274
- generic = true
275
- context.report({ node: param, messageId: "returnOnly", data: { name, param: spelled } })
276
- }
277
- }
278
- }
279
345
  if (fn.body === null || fn.body === undefined) return
280
- const loose = new Map()
281
- for (const param of fn.params) {
282
- const target = param.type === "AssignmentPattern" ? param.left : param
283
- const kind = target.type === "Identifier" ? target.typeAnnotation?.typeAnnotation?.type : undefined
284
- if (kind === "TSUnknownKeyword" || kind === "TSAnyKeyword") loose.set(target.name, kind === "TSAnyKeyword" ? "any" : "unknown")
285
- }
286
- const concrete = returned !== undefined && returned.type !== "TSUnknownKeyword" && returned.type !== "TSAnyKeyword"
346
+ const loose = looseParameters(fn)
287
347
  for (const expression of returnedExpressions(fn)) {
288
- const value = peel(expression)
289
- if (!generic && value.type === "Identifier" && loose.has(value.name) && (concrete || value !== expression)) {
290
- context.report({ node: expression, messageId: "launder", data: { name, kind: loose.get(value.name) } })
291
- return
292
- }
293
- const klass = assignedClassOf(value)
294
- if (klass !== null && returned?.type === "TSTypeReference" && returned.typeName.type === "Identifier" && returned.typeName.name !== klass) {
295
- context.report({ node: expression, messageId: "assign", data: { klass, returned: returned.typeName.name } })
296
- }
348
+ if (reportHelperReturn(context, expression, { name, generic, loose, returned })) return
297
349
  }
298
350
  }
299
351
  return {
@@ -316,7 +368,7 @@ export const noReturnOnlyGeneric = {
316
368
  // -- spec-infra-double-from-kit --------------------------------------------------------------------------------
317
369
 
318
370
  /** `EntityManager` -> `ENTITY_MANAGER`, `redis-cache` -> `REDIS_CACHE`. */
319
- 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, "_")
371
+ 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, "_")
320
372
 
321
373
  /** The name a provider token spells: an identifier, the last member of `Tokens.CLOCK`, a string, a called token factory's name. */
322
374
  const tokenName = (node) => {
@@ -339,6 +391,18 @@ const tableOf = (specDoubles) => {
339
391
  return compiled
340
392
  }
341
393
 
394
+ /** A const identifier's initializer, or null when its value cannot be followed. */
395
+ const constantInitializer = (context, value) => {
396
+ for (let scope = context.sourceCode.getScope(value); scope; scope = scope.upper) {
397
+ const variable = scope.set.get(value.name)
398
+ if (!variable) continue
399
+ const definition = variable.defs[0]
400
+ if (definition?.type === "Variable" && definition.node.id.type === "Identifier" && definition.node.init && definition.parent.kind === "const") return definition.node.init
401
+ return null
402
+ }
403
+ return null
404
+ }
405
+
342
406
  /** A value's form and the double it names: `{ form, name }`, or null when it is not a form the table knows. */
343
407
  const formOf = (context, node, depth = 0) => {
344
408
  const value = peel(node)
@@ -349,6 +413,12 @@ const formOf = (context, node, depth = 0) => {
349
413
  return null
350
414
  case "NewExpression":
351
415
  return value.callee.type === "Identifier" ? { form: "new", name: value.callee.name, node: value.callee } : null
416
+ case "MemberExpression": {
417
+ // a member of a kit `mock<T>()` object is that mock's double (`mock<{ open: F }>().open`)
418
+ if (value.computed || depth >= 3) return null
419
+ const owner = formOf(context, value.object, depth + 1)
420
+ return owner?.form === "call" && owner.name === "mock" ? owner : null
421
+ }
352
422
  case "ObjectExpression":
353
423
  return { form: "object" }
354
424
  case "ArrayExpression":
@@ -359,14 +429,8 @@ const formOf = (context, node, depth = 0) => {
359
429
  return value.expressions.length === 0 ? { form: "primitive" } : null
360
430
  case "Identifier": {
361
431
  if (depth >= 3) return null
362
- for (let scope = context.sourceCode.getScope(value); scope; scope = scope.upper) {
363
- const variable = scope.set.get(value.name)
364
- if (!variable) continue
365
- const definition = variable.defs[0]
366
- if (definition?.type === "Variable" && definition.node.id.type === "Identifier" && definition.node.init && definition.parent.kind === "const") return formOf(context, definition.node.init, depth + 1)
367
- return null
368
- }
369
- return null
432
+ const initializer = constantInitializer(context, value)
433
+ return initializer === null ? null : formOf(context, initializer, depth + 1)
370
434
  }
371
435
  default:
372
436
  return null
@@ -382,21 +446,36 @@ const kitDoubleOfType = (context, node, kit) => {
382
446
  return found ? KIT_TYPES[found.name] : null
383
447
  }
384
448
 
449
+ /** How an actual double value is written in a diagnostic. */
450
+ const describeDoubleValue = (found) => {
451
+ if (found.name === undefined) return `a ${found.form} value`
452
+ const prefix = found.form === "new" ? "new " : ""
453
+ return `\`${prefix}${found.name}(...)\``
454
+ }
455
+
456
+ /** Judge a provider that is traced by its declared type instead of its initializer. */
457
+ const checkTypedDouble = (context, node, table, entry, data) => {
458
+ const typedAs = kitDoubleOfType(context, node.value, table.kit)
459
+ if (typedAs === null) return context.report({ node: node.value, messageId: "unknown", data })
460
+ if (typedAs !== entry.double) return context.report({ node: node.value, messageId: "wrong", data: { ...data, got: `a \`${typedAs}\` double` } })
461
+ return undefined
462
+ }
463
+
385
464
  /** The double a token's provider value must come from. */
386
465
  export const specInfraDoubleFromKit = {
387
466
  meta: {
388
467
  type: "problem",
389
- 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`)." },
468
+ docs: { description: "In a logic-role unit spec a provider `{ provide: TOKEN, useValue: X }` takes X from the `@starci/jest-preset` double the token calls for (`ruleParams.be.specDoubles`)." },
390
469
  schema: [],
391
470
  messages: {
392
- 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.",
393
- 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.",
394
- 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).",
471
+ wrong: "[BE_SPEC_FORM_ALL_ROLES] `{{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.",
472
+ notKit: "[BE_SPEC_FORM_ALL_ROLES] `{{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.",
473
+ unknown: "[BE_SPEC_FORM_ALL_ROLES] 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).",
395
474
  },
396
475
  },
397
476
  create(context) {
398
477
  const hfs = hfsOf(context)
399
- if (!isServiceSpecFile(hfs, context.filename || context.getFilename())) return {}
478
+ if (!isFormSpecFile(hfs, context.filename || context.getFilename())) return {}
400
479
  const table = tableOf(hfs.ruleParams.specDoubles)
401
480
  const describe = (entry) => {
402
481
  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])
@@ -405,6 +484,7 @@ export const specInfraDoubleFromKit = {
405
484
  return {
406
485
  Property(node) {
407
486
  if (node.parent.type !== "ObjectExpression" || (node.key.name ?? node.key.value) !== "useValue") return
487
+ if (isRealSubjectValue(context, node.value)) return
408
488
  const provide = node.parent.properties.find((property) => property.type === "Property" && (property.key.name ?? property.key.value) === "provide")
409
489
  const spelled = tokenName(provide?.value)
410
490
  if (spelled === null) return
@@ -415,18 +495,15 @@ export const specInfraDoubleFromKit = {
415
495
  const data = { token: spelled, want, kit: table.kit }
416
496
  if (found === null) {
417
497
  // not a call, a literal or a traceable const: the declared type of the value decides (a helper parameter, `tx.em`)
418
- const typedAs = kitDoubleOfType(context, node.value, table.kit)
419
- if (typedAs === null) return context.report({ node: node.value, messageId: "unknown", data })
420
- if (typedAs !== entry.double) return context.report({ node: node.value, messageId: "wrong", data: { ...data, got: `a \`${typedAs}\` double` } })
421
- return undefined
498
+ return checkTypedDouble(context, node, table, entry, data)
422
499
  }
423
500
  if (!entry.forms.includes(found.form)) {
424
- const got = found.name ? `\`${found.form === "new" ? "new " : ""}${found.name}(...)\`` : `a ${found.form} value`
501
+ const got = describeDoubleValue(found)
425
502
  return context.report({ node: node.value, messageId: "wrong", data: { ...data, got } })
426
503
  }
427
504
  if (found.name === undefined) return undefined
428
505
  const imported = importOf(context, found.node)
429
- if (found.name !== entry.double) return context.report({ node: node.value, messageId: "wrong", data: { ...data, got: `\`${found.form === "new" ? "new " : ""}${found.name}(...)\`` } })
506
+ if (found.name !== entry.double) return context.report({ node: node.value, messageId: "wrong", data: { ...data, got: describeDoubleValue(found) } })
430
507
  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 } })
431
508
  return undefined
432
509
  },
@@ -443,14 +520,14 @@ const LOOSE_KINDS = new Set(["String", "Number", "Date"])
443
520
  export const specExactValues = {
444
521
  meta: {
445
522
  type: "problem",
446
- docs: { description: "A service spec never asserts `expect.any(String)`, `expect.any(Number)` or `expect.any(Date)`: ids and clocks come from `fakeIds()` and `FakeClock`, so the exact value is asserted." },
523
+ docs: { description: "A logic-role unit spec never asserts `expect.any(String)`, `expect.any(Number)` or `expect.any(Date)`: ids and clocks come from `fakeIds()` and `FakeClock`, so the exact value is asserted." },
447
524
  schema: [],
448
525
  messages: {
449
- loose: "`expect.any({{kind}})` accepts every {{what}}, so the assertion proves nothing about the value the service generated. The service takes its ids and its clock from tokens and the spec provides the kit's deterministic doubles: `{ provide: ID_GENERATOR, useValue: fakeIds() }` and `{ provide: CLOCK, useValue: new FakeClock(...) }` from `@starci/jest-preset`. Assert the exact value ({{exact}}).",
526
+ loose: "[BE_SPEC_FORM_ALL_ROLES] `expect.any({{kind}})` accepts every {{what}}, so the assertion proves nothing about the value the subject generated. The subject takes its ids and its clock from tokens and the spec provides the kit's deterministic doubles: `{ provide: ID_GENERATOR, useValue: fakeIds() }` and `{ provide: CLOCK, useValue: new FakeClock(...) }` from `@starci/jest-preset`. Assert the exact value ({{exact}}).",
450
527
  },
451
528
  },
452
529
  create(context) {
453
- if (!isServiceSpecFile(hfsOf(context), context.filename || context.getFilename())) return {}
530
+ if (!isFormSpecFile(hfsOf(context), context.filename || context.getFilename())) return {}
454
531
  return {
455
532
  CallExpression(node) {
456
533
  const callee = node.callee
@@ -466,6 +543,113 @@ export const specExactValues = {
466
543
  },
467
544
  }
468
545
 
546
+ // -- spec-behavior-assertion -----------------------------------------------------------------------------------
547
+
548
+ const PRESENCE_MATCHERS = new Set(["toBeDefined", "toBeTruthy"])
549
+
550
+ /** The matcher in `expect(value).not.resolves.toEqual(...)`, or null for another receiver. */
551
+ const matcherOf = (context, node) => {
552
+ const callee = node.callee
553
+ if (callee.type !== "MemberExpression") return null
554
+ const matcher = callee.computed ? callee.property.value : callee.property.name
555
+ if (typeof matcher !== "string" || !matcher.startsWith("to")) return null
556
+ let receiver = callee.object
557
+ while (receiver.type === "MemberExpression") receiver = receiver.object
558
+ if (receiver.type !== "CallExpression") return null
559
+ if (!isImportedFrom(context, receiver.callee, "@jest/globals", "expect") && !isAmbientOrImported(context, receiver.callee, "expect", "@jest/globals")) return null
560
+ return matcher
561
+ }
562
+
563
+ const TEST_MODIFIERS = new Set(["only", "skip", "concurrent", "failing", "each"])
564
+
565
+ /** A Jest test registration, including modifiers and both forms of `each`. */
566
+ const isTestFunction = (context, node) => {
567
+ if (!node) return false
568
+ if (isImportedFrom(context, node, "@jest/globals", ["it", "test"])) return true
569
+ if (isAmbientOrImported(context, node, "it", "@jest/globals") || isAmbientOrImported(context, node, "test", "@jest/globals")) return true
570
+ if (node.type === "MemberExpression" && TEST_MODIFIERS.has(keyName(node.property))) return isTestFunction(context, node.object)
571
+ if (node.type === "CallExpression" && node.callee.type === "MemberExpression" && keyName(node.callee.property) === "each") return isTestFunction(context, node.callee.object)
572
+ if (node.type === "TaggedTemplateExpression" && node.tag.type === "MemberExpression" && keyName(node.tag.property) === "each") return isTestFunction(context, node.tag.object)
573
+ return false
574
+ }
575
+
576
+ /** The local value an identifier binds, when it can name a callback or assertion helper. */
577
+ const functionBinding = (context, node) => {
578
+ for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) {
579
+ const variable = scope.set.get(node.name)
580
+ if (!variable) continue
581
+ const definition = variable.defs[0]
582
+ if (definition?.type === "FunctionName") return definition.node
583
+ if (definition?.type === "Variable") return definition.node.init
584
+ return null
585
+ }
586
+ return null
587
+ }
588
+
589
+ /** An inline function or a locally bound function, following aliases without cycles. */
590
+ const functionOf = (context, expression) => {
591
+ let node = peel(expression)
592
+ const seen = new Set()
593
+ while (node?.type === "Identifier") {
594
+ if (seen.has(node)) return null
595
+ seen.add(node)
596
+ node = peel(functionBinding(context, node))
597
+ }
598
+ if (!node) return null
599
+ return ["ArrowFunctionExpression", "FunctionExpression", "FunctionDeclaration"].includes(node.type) ? node : null
600
+ }
601
+
602
+ /** Assertions in the callback's executed syntax, including local helpers and passed callbacks. */
603
+ const hasAssertion = (context, fn, seen = new Set()) => {
604
+ if (seen.has(fn)) return false
605
+ seen.add(fn)
606
+ let found = false
607
+ walk(fn.body, (node) => {
608
+ if (found || node.type !== "CallExpression") return
609
+ if (matcherOf(context, node) !== null) {
610
+ found = true
611
+ return
612
+ }
613
+ if (isTestFunction(context, node.callee)) return
614
+ const functions = [node.callee, ...node.arguments].map((expression) => functionOf(context, expression)).filter(Boolean)
615
+ found = functions.some((helper) => hasAssertion(context, helper, seen))
616
+ }, { intoFunctions: false })
617
+ return found
618
+ }
619
+
620
+ /** Presence alone does not prove the behavior of a logic-role subject. */
621
+ export const specBehaviorAssertion = {
622
+ meta: {
623
+ type: "problem",
624
+ docs: { description: "Every logic-role unit test has an assertion, and its spec asserts behavior beyond presence or truthiness (R281 BE_SPEC_FORM_ALL_ROLES)." },
625
+ schema: [],
626
+ messages: {
627
+ presenceOnly: "[BE_SPEC_FORM_ALL_ROLES] Every assertion in this spec checks only presence or truthiness. Assert an exact result, error or observable effect of the subject's behavior.",
628
+ zeroAssertions: "[BE_SPEC_FORM_ALL_ROLES] This unit test has no assertion. Assert an exact result, error or observable effect of the subject's behavior in every `it`/`test` callback.",
629
+ },
630
+ },
631
+ create(context) {
632
+ if (!isFormSpecFile(hfsOf(context), context.filename || context.getFilename())) return {}
633
+ let firstPresence = null
634
+ let behavior = false
635
+ return {
636
+ CallExpression(node) {
637
+ if (isTestFunction(context, node.callee)) {
638
+ const callback = functionOf(context, node.arguments[1])
639
+ if (callback !== null && !hasAssertion(context, callback)) context.report({ node, messageId: "zeroAssertions" })
640
+ }
641
+ const matcher = matcherOf(context, node)
642
+ if (matcher === null) return
643
+ if (PRESENCE_MATCHERS.has(matcher)) firstPresence ??= node
644
+ else behavior = true
645
+ },
646
+ "Program:exit"() {
647
+ if (firstPresence !== null && !behavior) context.report({ node: firstPresence, messageId: "presenceOnly" })
648
+ },
649
+ }
650
+ },
651
+ }
652
+
469
653
  /** The rules this law contributes to the plugin. */
470
654
  export const rules = {
471
655
  "spec-builds-with-testing-module": specBuildsWithTestingModule,
@@ -475,6 +659,7 @@ export const rules = {
475
659
  "no-return-only-generic": noReturnOnlyGeneric,
476
660
  "spec-infra-double-from-kit": specInfraDoubleFromKit,
477
661
  "spec-exact-values": specExactValues,
662
+ "spec-behavior-assertion": specBehaviorAssertion,
478
663
  }
479
664
 
480
665
  /** Every rule of this law ships at `error`. */
@@ -486,4 +671,5 @@ export const recommended = {
486
671
  "starci-be/no-return-only-generic": "error",
487
672
  "starci-be/spec-infra-double-from-kit": "error",
488
673
  "starci-be/spec-exact-values": "error",
674
+ "starci-be/spec-behavior-assertion": "error",
489
675
  }