@starci/eslint-canon-be 3.3.1 → 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.
- package/CHANGELOG.md +12 -0
- package/README.md +2 -2
- package/app-composition.mjs +348 -3
- package/authorization.mjs +68 -2
- package/builders.mjs +2 -2
- package/config-owner.mjs +44 -15
- package/default-deny.mjs +80 -9
- package/edge-hardening.mjs +139 -0
- package/feature-thin.mjs +78 -2
- package/function-budget.mjs +49 -0
- package/health-ready.mjs +539 -0
- package/index.mjs +37 -0
- package/infra-owner.mjs +3 -0
- package/jwt-alg.mjs +45 -0
- package/lib/subject-values.mjs +55 -0
- package/lib/unit-spec.mjs +0 -3
- package/lib/webhook-handoff-inbox.mjs +260 -0
- package/lib/webhook-handoff-intake.mjs +140 -0
- package/lib/why.mjs +13 -0
- package/module-shape.mjs +147 -51
- package/operations.mjs +165 -1
- package/package.json +1 -1
- package/realtime.mjs +59 -22
- package/resilience.mjs +45 -14
- package/runtime/knowledge/hfs/canon-pins.yaml +11 -11
- package/runtime/knowledge/hfs/fe-stack.yaml +22 -1
- package/runtime/knowledge/hfs/rules.yaml +3401 -152
- package/runtime/knowledge/hfs/slots.yaml +344 -62
- package/runtime/knowledge/patterns/fe/folder.yaml +1 -0
- package/runtime/knowledge/sonar-gate.yaml +1 -1
- package/runtime/modules/models/runtimes.yaml +1 -1
- package/runtime/packages/hfs/templates/app/package-scripts/package.json +1 -0
- package/runtime/scripts/api/git/lib.mjs +3 -0
- package/runtime/scripts/api/git/merge-base.mjs +9 -0
- package/runtime/scripts/api/git/recorded-lines.mjs +54 -8
- package/runtime/scripts/hfs/architecture/backend-app-files.mjs +1 -8
- package/runtime/scripts/hfs/architecture/backend.mjs +2 -7
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +6 -3
- package/runtime/scripts/hfs/architecture/dead-exports-bindings.mjs +149 -0
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +9 -41
- package/runtime/scripts/hfs/architecture/default-deny.mjs +8 -6
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +1 -1
- package/runtime/scripts/hfs/architecture/function-spec-providers.mjs +153 -0
- package/runtime/scripts/hfs/architecture/hfs.mjs +3 -3
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +28 -13
- package/runtime/scripts/hfs/architecture/next-data-inference.mjs +176 -0
- package/runtime/scripts/hfs/architecture/next-data-symbols.mjs +26 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +31 -23
- package/runtime/scripts/hfs/architecture/reachability.mjs +14 -0
- package/runtime/scripts/hfs/architecture/register-once.mjs +2 -2
- package/runtime/scripts/hfs/architecture/source-names.mjs +48 -3
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +211 -11
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase.mjs +59 -21
- package/runtime/scripts/hfs/architecture/surface.mjs +2 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +3 -3
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +8 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +39 -3
- package/runtime/scripts/hfs/edition-slots.mjs +2 -2
- package/runtime/scripts/hfs/manifest-shape.mjs +67 -7
- package/runtime/scripts/hfs/project-rule.mjs +3 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +156 -11
- package/runtime/scripts/hfs/rule-params-shape.mjs +22 -10
- package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
- package/runtime/scripts/hfs/slot-imports.mjs +18 -5
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/lib/env.mjs +20 -0
- package/runtime/scripts/lib/event-contract.mjs +107 -0
- package/runtime/scripts/lib/json-clone.mjs +61 -0
- package/runtime/scripts/lib/list.mjs +4 -0
- package/runtime/scripts/lib/secret-patterns.mjs +60 -5
- package/runtime/scripts/lib/stack-services.mjs +1 -1
- package/runtime/scripts/lib/ts-analysis.mjs +5 -2
- package/runtime/scripts/lib/ts-ast.mjs +27 -2
- package/service-isolation.mjs +3 -0
- package/size-budget.mjs +9 -5
- package/third-party-owner.mjs +149 -0
- package/transport.mjs +11 -29
- package/unit-spec.mjs +270 -84
- 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 (
|
|
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>.
|
|
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
|
|
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,
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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 (!
|
|
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
|
|
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
|
|
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
|
|
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 (!
|
|
109
|
-
const stem =
|
|
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)
|
|
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
|
|
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
|
|
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 (!
|
|
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 =
|
|
262
|
-
const
|
|
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 =
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
363
|
-
|
|
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
|
|
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 (!
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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 (!
|
|
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
|
}
|