@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.
- package/CHANGELOG.md +95 -5
- package/README.md +41 -52
- package/async-discipline.mjs +2 -2
- package/authorization.mjs +38 -88
- package/builders.mjs +305 -0
- package/comments.mjs +19 -34
- package/config-owner.mjs +127 -125
- package/connections.mjs +254 -0
- package/cqrs.mjs +426 -135
- package/data-access.mjs +320 -391
- package/default-deny.mjs +14 -10
- package/e2e-flow.mjs +208 -180
- package/error-handling.mjs +254 -128
- package/idempotency.mjs +98 -67
- package/index.mjs +32 -9
- package/infra-owner.mjs +96 -0
- package/injection.mjs +602 -0
- package/input-bounds.mjs +166 -66
- package/lib/config.mjs +70 -33
- package/lib/db-writes.mjs +58 -0
- package/lib/declared.mjs +154 -0
- package/lib/hfs.mjs +61 -0
- package/lib/import-source.mjs +104 -0
- package/lib/persistence.mjs +160 -0
- package/lib/ports.mjs +138 -0
- package/lib/transport-slots.mjs +14 -0
- package/lib/types.mjs +133 -0
- package/lib/unit-spec.mjs +33 -0
- package/log-safety.mjs +94 -26
- package/module-layering.mjs +180 -297
- package/module-shape.mjs +296 -59
- package/naming.mjs +44 -12
- package/observability.mjs +57 -91
- package/operations.mjs +201 -0
- package/package.json +10 -7
- package/query-safety.mjs +189 -223
- package/resilience.mjs +126 -31
- package/runtime/engine/plain-object.mjs +5 -0
- package/runtime/engine/runtime-root.mjs +32 -0
- package/runtime/engine/yaml.mjs +161 -0
- package/runtime/knowledge/hfs/slots.yaml +1111 -0
- package/runtime/scripts/lib/git.mjs +53 -0
- package/runtime/scripts/lib/glob.mjs +23 -0
- package/runtime/scripts/lib/hfs-allows.mjs +57 -0
- package/runtime/scripts/lib/hfs-slots.mjs +729 -0
- package/runtime/scripts/lib/hfs-view.mjs +68 -0
- package/runtime/scripts/lib/language.mjs +107 -0
- package/runtime/scripts/lib/path-key.mjs +17 -0
- package/runtime/scripts/lib/recorded-lines.mjs +27 -0
- package/schema-authority.mjs +273 -91
- package/size-budget.mjs +19 -61
- package/spec-no-skip.mjs +84 -0
- package/spec-quality.mjs +76 -35
- package/suppression.mjs +34 -16
- package/temporal.mjs +101 -36
- package/test-world.mjs +204 -0
- package/testing.mjs +99 -251
- package/transport.mjs +433 -142
- package/type-safety.mjs +50 -278
- package/unit-spec.mjs +452 -0
- package/user-copy.mjs +56 -37
- 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
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
* `"
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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 {
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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-
|
|
53
|
-
notification: "`{{property}}` of this
|
|
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 =
|
|
59
|
-
if (isDeclarationFile(filename)
|
|
60
|
-
const
|
|
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))
|
|
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 (!
|
|
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 (
|
|
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
|
}
|