@starci/eslint-canon-be 1.2.2 → 1.7.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 +43 -0
- package/README.md +82 -7
- package/async-discipline.mjs +83 -0
- package/comments.mjs +7 -55
- package/config-owner.mjs +252 -0
- package/cqrs.mjs +7 -141
- package/data-access.mjs +151 -5
- package/default-deny.mjs +116 -0
- package/error-handling.mjs +207 -0
- package/idempotency.mjs +101 -0
- package/index.mjs +34 -4
- package/input-bounds.mjs +84 -0
- package/lib/ast.mjs +81 -0
- package/lib/config.mjs +49 -0
- package/lib/path.mjs +15 -0
- package/lib/slots.mjs +60 -0
- package/log-safety.mjs +148 -0
- package/module-layering.mjs +87 -105
- package/module-shape.mjs +202 -0
- package/package.json +1 -1
- package/query-safety.mjs +274 -0
- package/resilience.mjs +178 -0
- package/schema-authority.mjs +187 -0
- package/size-budget.mjs +106 -0
- package/spec-quality.mjs +132 -0
- package/suppression.mjs +60 -0
- package/temporal.mjs +71 -0
- package/testing.mjs +31 -19
- package/type-safety.mjs +98 -57
- package/user-copy.mjs +106 -0
- package/exception-identity.mjs +0 -200
- package/exceptions.mjs +0 -262
package/module-shape.mjs
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that hold the shape of a Nest module in HFS (catalog R45 `BE_MODULE_SHAPE`).
|
|
3
|
+
*
|
|
4
|
+
* - `global-module-allowlist` lets `@Global()` appear only on the platform capabilities the slot manifest
|
|
5
|
+
* names (`ruleParams.be.globalModules`; read through `lib/slots.mjs`).
|
|
6
|
+
* A global module hides who depends on whom, so it is reserved for the three every capability needs.
|
|
7
|
+
* - `typed-module-definition` requires `ConfigurableModuleBuilder<Options>` to state its options type.
|
|
8
|
+
* - `static-module-register` requires `register`, `registerAsync`, `forRoot` and `forFeature` to be `static`.
|
|
9
|
+
* - `no-new-injectable` refuses `new SomeService()` (and other injectable-shaped names) outside specs: the
|
|
10
|
+
* container builds providers, so construction by hand bypasses its scope and its overrides.
|
|
11
|
+
* - `no-module-let` refuses mutable module-level state (`let` and `var` at the top of a file).
|
|
12
|
+
* - `one-module-per-file` allows one `@Module` class in a file, so one transport or application module has
|
|
13
|
+
* one home.
|
|
14
|
+
*
|
|
15
|
+
* `no-self-global-module` of the earlier canon is gone: it refused `@Global()` everywhere, which also refused
|
|
16
|
+
* the three platform capabilities HFS requires to be global.
|
|
17
|
+
*/
|
|
18
|
+
import { decoratorName } from "./lib/ast.mjs"
|
|
19
|
+
import { isDeclarationFile, isTestLane, normalizePath } from "./lib/path.mjs"
|
|
20
|
+
import { hfsParams } from "./lib/slots.mjs"
|
|
21
|
+
|
|
22
|
+
/** `platform/<capability>` of a file inside `src/modules/platform`, else null. */
|
|
23
|
+
const platformCapability = (filename) => {
|
|
24
|
+
const match = /\/src\/modules\/(platform\/[^/]+)\//.exec(filename)
|
|
25
|
+
return match ? match[1] : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `@Global()` only on an allowlisted platform capability. */
|
|
29
|
+
export const globalModuleAllowlist = {
|
|
30
|
+
meta: {
|
|
31
|
+
type: "problem",
|
|
32
|
+
docs: { description: "`@Global()` only for the manifest's platform allowlist." },
|
|
33
|
+
schema: [
|
|
34
|
+
{
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: { allowed: { type: "array", items: { type: "string" } } },
|
|
37
|
+
additionalProperties: false,
|
|
38
|
+
},
|
|
39
|
+
],
|
|
40
|
+
messages: {
|
|
41
|
+
global: "`@Global()` is allowed only on {{allowed}}. A global module hides its dependants; import the module where it is used, or pass options through `register`.",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
create(context) {
|
|
45
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
46
|
+
if (isDeclarationFile(filename) || isTestLane(filename)) return {}
|
|
47
|
+
const allowed = context.options[0]?.allowed ?? hfsParams.globalModules
|
|
48
|
+
const capability = platformCapability(filename)
|
|
49
|
+
if (capability !== null && allowed.includes(capability)) return {}
|
|
50
|
+
return {
|
|
51
|
+
Decorator(node) {
|
|
52
|
+
if (decoratorName(node) === "Global") context.report({ node, messageId: "global", data: { allowed: allowed.map((name) => `\`${name}\``).join(", ") } })
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A configurable module builder names its options type. */
|
|
59
|
+
export const typedModuleDefinition = {
|
|
60
|
+
meta: {
|
|
61
|
+
type: "problem",
|
|
62
|
+
docs: { description: "`new ConfigurableModuleBuilder<Options>()` states its options type." },
|
|
63
|
+
schema: [],
|
|
64
|
+
messages: {
|
|
65
|
+
untyped: "`ConfigurableModuleBuilder` has no options type. Write `new ConfigurableModuleBuilder<{{example}}Options>()` so `register` is checked.",
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
create(context) {
|
|
69
|
+
if (isDeclarationFile(context.filename || context.getFilename())) return {}
|
|
70
|
+
return {
|
|
71
|
+
NewExpression(node) {
|
|
72
|
+
if (node.callee.type !== "Identifier" || node.callee.name !== "ConfigurableModuleBuilder") return
|
|
73
|
+
const typed = (node.typeArguments ?? node.typeParameters)?.params?.length > 0
|
|
74
|
+
if (!typed) context.report({ node, messageId: "untyped", data: { example: "Capability" } })
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const REGISTER_NAMES = new Set(["register", "registerAsync", "forRoot", "forRootAsync", "forFeature"])
|
|
81
|
+
|
|
82
|
+
/** The dynamic-module factory methods are `static`. */
|
|
83
|
+
export const staticModuleRegister = {
|
|
84
|
+
meta: {
|
|
85
|
+
type: "problem",
|
|
86
|
+
docs: { description: "`register`, `registerAsync`, `forRoot` and `forFeature` are static." },
|
|
87
|
+
schema: [],
|
|
88
|
+
messages: {
|
|
89
|
+
notStatic: "`{{name}}` builds a dynamic module and must be `static`; an instance method cannot be called from `imports`.",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
create(context) {
|
|
93
|
+
if (isDeclarationFile(context.filename || context.getFilename())) return {}
|
|
94
|
+
return {
|
|
95
|
+
ClassDeclaration(node) {
|
|
96
|
+
if (!node.decorators?.some((decorator) => decoratorName(decorator) === "Module")) return
|
|
97
|
+
for (const member of node.body.body) {
|
|
98
|
+
if (member.type !== "MethodDefinition" || member.static || member.computed) continue
|
|
99
|
+
if (member.key.type === "Identifier" && REGISTER_NAMES.has(member.key.name)) {
|
|
100
|
+
context.report({ node: member, messageId: "notStatic", data: { name: member.key.name } })
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const INJECTABLE_SUFFIX = /(?:Service|Repository|Guard|Interceptor|Resolver|Consumer|UseCase)$/
|
|
109
|
+
|
|
110
|
+
/** Providers are built by the container, not by `new`. */
|
|
111
|
+
export const noNewInjectable = {
|
|
112
|
+
meta: {
|
|
113
|
+
type: "problem",
|
|
114
|
+
docs: { description: "No `new SomeService()` for an injectable-shaped class outside a spec." },
|
|
115
|
+
schema: [],
|
|
116
|
+
messages: {
|
|
117
|
+
construct: "`new {{name}}()` builds a provider by hand, bypassing the container's scope and overrides. Inject it, or register it as a provider.",
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
create(context) {
|
|
121
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
122
|
+
if (isDeclarationFile(filename) || isTestLane(filename)) return {}
|
|
123
|
+
return {
|
|
124
|
+
NewExpression(node) {
|
|
125
|
+
if (node.callee.type === "Identifier" && INJECTABLE_SUFFIX.test(node.callee.name)) {
|
|
126
|
+
context.report({ node, messageId: "construct", data: { name: node.callee.name } })
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** No mutable state at module scope. */
|
|
134
|
+
export const noModuleLet = {
|
|
135
|
+
meta: {
|
|
136
|
+
type: "problem",
|
|
137
|
+
docs: { description: "No `let` or `var` at module scope." },
|
|
138
|
+
schema: [],
|
|
139
|
+
messages: {
|
|
140
|
+
mutable: "`{{kind}}` at module scope is process-wide mutable state shared by every request and test. Keep the state in an injected provider.",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
create(context) {
|
|
144
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
145
|
+
if (isDeclarationFile(filename) || isTestLane(filename)) return {}
|
|
146
|
+
const check = (node) => {
|
|
147
|
+
if (node.type === "VariableDeclaration" && node.kind !== "const") context.report({ node, messageId: "mutable", data: { kind: node.kind } })
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
Program(node) {
|
|
151
|
+
for (const statement of node.body) {
|
|
152
|
+
check(statement)
|
|
153
|
+
if (statement.type === "ExportNamedDeclaration" && statement.declaration) check(statement.declaration)
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** One `@Module` class per file. */
|
|
161
|
+
export const oneModulePerFile = {
|
|
162
|
+
meta: {
|
|
163
|
+
type: "problem",
|
|
164
|
+
docs: { description: "A file declares at most one `@Module` class." },
|
|
165
|
+
schema: [],
|
|
166
|
+
messages: {
|
|
167
|
+
many: "This file declares a second `@Module` class. One transport or application module has one file; split this one out.",
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
create(context) {
|
|
171
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
172
|
+
if (isDeclarationFile(filename) || isTestLane(filename)) return {}
|
|
173
|
+
let seen = 0
|
|
174
|
+
return {
|
|
175
|
+
ClassDeclaration(node) {
|
|
176
|
+
if (!node.decorators?.some((decorator) => decoratorName(decorator) === "Module")) return
|
|
177
|
+
seen += 1
|
|
178
|
+
if (seen > 1) context.report({ node, messageId: "many" })
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The rules this law contributes to the plugin. */
|
|
185
|
+
export const rules = {
|
|
186
|
+
"global-module-allowlist": globalModuleAllowlist,
|
|
187
|
+
"typed-module-definition": typedModuleDefinition,
|
|
188
|
+
"static-module-register": staticModuleRegister,
|
|
189
|
+
"no-new-injectable": noNewInjectable,
|
|
190
|
+
"no-module-let": noModuleLet,
|
|
191
|
+
"one-module-per-file": oneModulePerFile,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** All start at error: HFS has one module shape and no baseline. */
|
|
195
|
+
export const recommended = {
|
|
196
|
+
"starci-be/global-module-allowlist": "error",
|
|
197
|
+
"starci-be/typed-module-definition": "error",
|
|
198
|
+
"starci-be/static-module-register": "error",
|
|
199
|
+
"starci-be/no-new-injectable": "error",
|
|
200
|
+
"starci-be/no-module-let": "error",
|
|
201
|
+
"starci-be/one-module-per-file": "error",
|
|
202
|
+
}
|
package/package.json
CHANGED
package/query-safety.mjs
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that keep a database call safe to run in production.
|
|
3
|
+
*
|
|
4
|
+
* - `no-interpolated-sql` (R68 `BE_SQL_INTERPOLATED`) refuses SQL text built from a runtime value. A value
|
|
5
|
+
* reaches the statement as a numbered parameter (`$1`), never as spliced text.
|
|
6
|
+
* - `query-needs-limit` (R69 `BE_QUERY_UNBOUNDED`) refuses a read that returns every matching row. A list read
|
|
7
|
+
* states its bound: `take`, `limit`, `LIMIT`, or a page cursor.
|
|
8
|
+
*
|
|
9
|
+
* Both read one file's syntax only. Where the syntax cannot prove a call safe or unsafe (a statement held in a
|
|
10
|
+
* variable, an options object built elsewhere) the rule stays silent: a rule that guesses teaches authors to
|
|
11
|
+
* ignore it.
|
|
12
|
+
*/
|
|
13
|
+
import { keyName, staticText, walk } from "./lib/ast.mjs"
|
|
14
|
+
import { isDeclarationFile, isTestLane, normalizePath } from "./lib/path.mjs"
|
|
15
|
+
|
|
16
|
+
const SQL_VERB = /\b(?:SELECT|INSERT|UPDATE|DELETE|WITH)\b/i
|
|
17
|
+
|
|
18
|
+
/** A migration writes DDL once, by hand, with no caller input: interpolation there is a build step, not a hole. */
|
|
19
|
+
const isMigrationPath = (filename) => /\/migrations\/[^/]+\.[cm]?ts$/.test(normalizePath(filename))
|
|
20
|
+
|
|
21
|
+
/** The property a call goes through: `manager.query(...)` gives `query`. */
|
|
22
|
+
const calleeProperty = (node) =>
|
|
23
|
+
node.callee.type === "MemberExpression" && !node.callee.computed && node.callee.property.type === "Identifier"
|
|
24
|
+
? node.callee.property.name
|
|
25
|
+
: null
|
|
26
|
+
|
|
27
|
+
/** The last name of the receiver: `this.manager` gives `manager`, `em` gives `em`. */
|
|
28
|
+
const receiverName = (callee) => {
|
|
29
|
+
const object = callee.object
|
|
30
|
+
if (object.type === "Identifier") return object.name
|
|
31
|
+
if (object.type === "MemberExpression" && !object.computed && object.property.type === "Identifier") return object.property.name
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** UPPER_SNAKE names a module constant: a fixed column list is code, not input. */
|
|
36
|
+
const isConstantName = (name) => /^[A-Z][A-Z0-9_]*$/.test(name)
|
|
37
|
+
|
|
38
|
+
/** Identifier helpers that quote an identifier are the sanctioned way to put a name into a statement. */
|
|
39
|
+
const IDENT_HELPERS = new Set(["quoteIdent", "quoteIdentifier", "quoteIdentifiers", "sqlIdent", "sqlIdentifiers", "sqlSetClause", "escapeIdentifier"])
|
|
40
|
+
|
|
41
|
+
/** Whether a substitution inside a SQL template is one of the shapes that cannot carry input. */
|
|
42
|
+
const isSafeSubstitution = (expression, precedingText) => {
|
|
43
|
+
if (precedingText.endsWith("$")) return true
|
|
44
|
+
if (expression.type === "Identifier") return isConstantName(expression.name)
|
|
45
|
+
if (expression.type === "MemberExpression" && !expression.computed && expression.property.type === "Identifier") {
|
|
46
|
+
return isConstantName(expression.property.name)
|
|
47
|
+
}
|
|
48
|
+
if (expression.type === "CallExpression" && expression.callee.type === "Identifier") return IDENT_HELPERS.has(expression.callee.name)
|
|
49
|
+
// `${locked ? "FOR UPDATE" : ""}` chooses between fixed strings
|
|
50
|
+
if (expression.type === "ConditionalExpression") {
|
|
51
|
+
const fixed = (branch) => branch.type === "Literal" && typeof branch.value === "string"
|
|
52
|
+
return fixed(expression.consequent) && fixed(expression.alternate)
|
|
53
|
+
}
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// -- R68 -----------------------------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/** A statement is text plus parameters; a value never becomes part of the text. */
|
|
60
|
+
export const noInterpolatedSql = {
|
|
61
|
+
meta: {
|
|
62
|
+
type: "problem",
|
|
63
|
+
docs: { description: "SQL text passed to `.query(...)` contains no runtime substitution; values are numbered parameters." },
|
|
64
|
+
schema: [],
|
|
65
|
+
messages: {
|
|
66
|
+
interpolated:
|
|
67
|
+
"This SQL text is built from `{{expression}}`. A value spliced into a statement is an injection hole and defeats the plan cache. Pass the value as a numbered parameter (`$1`) in the array argument. A column list is a module constant in UPPER_SNAKE case; a dynamic identifier goes through `quoteIdent(...)` or `sqlIdent(...)`; a variable SET list goes through `sqlSetClause(...)`, which checks every column against a fixed allowlist and numbers the placeholders. A choice between fixed strings is a ternary of literals.",
|
|
68
|
+
concatenated:
|
|
69
|
+
"This SQL text is joined with `+`. A value spliced into a statement is an injection hole. Pass the value as a numbered parameter (`$1`) in the array argument.",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
create(context) {
|
|
73
|
+
const filename = context.filename || context.getFilename()
|
|
74
|
+
if (isTestLane(filename) || isDeclarationFile(filename) || isMigrationPath(filename)) return {}
|
|
75
|
+
const sourceCode = context.sourceCode || context.getSourceCode()
|
|
76
|
+
return {
|
|
77
|
+
CallExpression(node) {
|
|
78
|
+
if (calleeProperty(node) !== "query") return
|
|
79
|
+
const first = node.arguments[0]
|
|
80
|
+
if (!first) return
|
|
81
|
+
if (first.type === "TemplateLiteral" && first.expressions.length > 0) {
|
|
82
|
+
const text = first.quasis.map((quasi) => quasi.value.cooked ?? "").join(" ")
|
|
83
|
+
if (!SQL_VERB.test(text)) return
|
|
84
|
+
first.expressions.forEach((expression, index) => {
|
|
85
|
+
const preceding = first.quasis[index]?.value.cooked ?? ""
|
|
86
|
+
if (isSafeSubstitution(expression, preceding)) return
|
|
87
|
+
context.report({ node: expression, messageId: "interpolated", data: { expression: sourceCode.getText(expression).slice(0, 60) } })
|
|
88
|
+
})
|
|
89
|
+
} else if (first.type === "BinaryExpression" && first.operator === "+") {
|
|
90
|
+
let sql = false
|
|
91
|
+
walk(first, (child) => {
|
|
92
|
+
if (child.type === "Literal" && typeof child.value === "string" && SQL_VERB.test(child.value)) sql = true
|
|
93
|
+
})
|
|
94
|
+
if (sql) context.report({ node: first, messageId: "concatenated" })
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// -- R69 -----------------------------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
const AGGREGATE_ONLY = /^\s*(?:WITH\b[\s\S]*?\)\s*)?SELECT\s+(?:COUNT|SUM|MAX|MIN|AVG|EXISTS|NEXTVAL|NOW|CURRENT_|PG_|SET_CONFIG|1\b|TRUE|FALSE|\(?\s*SELECT\s+1)/i
|
|
104
|
+
const HAS_BOUND = /\bLIMIT\b|\bFETCH\s+FIRST\b/i
|
|
105
|
+
/** A lookup by the primary key returns one row, so it needs no bound. */
|
|
106
|
+
const BY_PRIMARY_KEY = /\bWHERE\s+"?(?:[a-z_]+\.)?id"?\s*=\s*\$\d+\s*(?:FOR\s+UPDATE\s*)?(?:RETURNING[^;]*)?$/i
|
|
107
|
+
|
|
108
|
+
const QUERY_BUILDER_READS = new Set(["getMany", "getRawMany", "getManyAndCount", "getRawAndEntities"])
|
|
109
|
+
const REPOSITORY_READS = new Set(["find", "findBy", "findAndCount", "findAndCountBy"])
|
|
110
|
+
const REPOSITORY_RECEIVER = /^(?:manager|entityManager|em|repo|repository|[a-z]\w*Repository|[a-z]\w*Repo)$/
|
|
111
|
+
|
|
112
|
+
/** The chain of calls a query builder read hangs off, as method names, innermost first. */
|
|
113
|
+
const chainNames = (callee) => {
|
|
114
|
+
const names = []
|
|
115
|
+
let current = callee
|
|
116
|
+
while (current) {
|
|
117
|
+
if (current.type === "MemberExpression" && !current.computed && current.property.type === "Identifier") {
|
|
118
|
+
names.push(current.property.name)
|
|
119
|
+
current = current.object
|
|
120
|
+
} else if (current.type === "CallExpression") {
|
|
121
|
+
current = current.callee
|
|
122
|
+
} else if (current.type === "AwaitExpression") {
|
|
123
|
+
current = current.argument
|
|
124
|
+
} else break
|
|
125
|
+
}
|
|
126
|
+
return names
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Whether an object literal names a property, by key. */
|
|
130
|
+
const objectHasKey = (node, names) =>
|
|
131
|
+
node?.type === "ObjectExpression" && node.properties.some((property) => property.type === "Property" && names.includes(keyName(property.key)))
|
|
132
|
+
|
|
133
|
+
/** Whether a `where` object states the primary key, which bounds the read to one row. */
|
|
134
|
+
const whereHasPrimaryKey = (node) => {
|
|
135
|
+
if (node?.type !== "ObjectExpression") return false
|
|
136
|
+
if (objectHasKey(node, ["id"])) return true
|
|
137
|
+
const where = node.properties.find((property) => property.type === "Property" && keyName(property.key) === "where")
|
|
138
|
+
return where ? whereHasPrimaryKey(where.value) : false
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A list read states its bound. */
|
|
142
|
+
export const queryNeedsLimit = {
|
|
143
|
+
meta: {
|
|
144
|
+
type: "problem",
|
|
145
|
+
docs: { description: "A read that can return many rows states `take`, `limit` or `LIMIT`." },
|
|
146
|
+
schema: [],
|
|
147
|
+
messages: {
|
|
148
|
+
builder:
|
|
149
|
+
"`.{{method}}()` reads every matching row: one busy tenant turns it into a full scan held in memory. Add `.take(n)` (or `.limit(n)`) with a named bound, or page with a cursor.",
|
|
150
|
+
find:
|
|
151
|
+
"`.{{method}}(...)` on a repository reads every matching row and states no `take`. Pass `take` with a named bound (the entity manager form is `manager.find(Entity, { where, take })`), or page with a cursor. A lookup by `id` is exempt.",
|
|
152
|
+
findBy:
|
|
153
|
+
"`.findBy(...)` cannot take a bound, so it reads every matching row. Use `.find({ where, take })` with a named bound, or page with a cursor. A lookup by `id` is exempt.",
|
|
154
|
+
raw:
|
|
155
|
+
"This `SELECT` has no `LIMIT`. Add `LIMIT $n` with a named bound, or page by keyset. An aggregate (`COUNT`, `EXISTS`, `SUM`) and a lookup by `id` are exempt.",
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
create(context) {
|
|
159
|
+
const filename = context.filename || context.getFilename()
|
|
160
|
+
if (isTestLane(filename) || isDeclarationFile(filename) || isMigrationPath(filename)) return {}
|
|
161
|
+
return {
|
|
162
|
+
CallExpression(node) {
|
|
163
|
+
const method = calleeProperty(node)
|
|
164
|
+
if (!method) return
|
|
165
|
+
if (QUERY_BUILDER_READS.has(method)) {
|
|
166
|
+
const names = chainNames(node.callee.object.type === "CallExpression" ? node.callee.object.callee : node.callee.object)
|
|
167
|
+
if (names.includes("take") || names.includes("limit") || names.includes("getOne") || names.includes("getOneOrFail")) return
|
|
168
|
+
if (!names.includes("createQueryBuilder") && !names.includes("select") && !names.includes("where") && !names.includes("andWhere") && !names.includes("leftJoinAndSelect") && !names.includes("innerJoinAndSelect") && !names.includes("orderBy") && !names.includes("from")) return
|
|
169
|
+
context.report({ node: node.callee.property, messageId: "builder", data: { method } })
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
if (REPOSITORY_READS.has(method) && REPOSITORY_RECEIVER.test(receiverName(node.callee) ?? "")) {
|
|
173
|
+
const args = node.arguments
|
|
174
|
+
// manager.find(Entity, options) has the entity first; repository.find(options) has the options first
|
|
175
|
+
const options = method === "find" || method === "findAndCount" ? args.find((arg) => arg.type === "ObjectExpression") : undefined
|
|
176
|
+
const where = method === "findBy" || method === "findAndCountBy" ? args.find((arg) => arg.type === "ObjectExpression") : undefined
|
|
177
|
+
if (method === "find" || method === "findAndCount") {
|
|
178
|
+
const argument = args[args.length - 1]
|
|
179
|
+
if (!argument || argument.type !== "ObjectExpression") return // no static options to judge
|
|
180
|
+
if (objectHasKey(options, ["take", "limit"]) || whereHasPrimaryKey(options)) return
|
|
181
|
+
context.report({ node: node.callee.property, messageId: "find", data: { method } })
|
|
182
|
+
} else if (where) {
|
|
183
|
+
if (whereHasPrimaryKey(where)) return
|
|
184
|
+
context.report({ node: node.callee.property, messageId: "findBy", data: { method } })
|
|
185
|
+
}
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
if (method === "query") {
|
|
189
|
+
const text = staticText(node.arguments[0])
|
|
190
|
+
const sql = text ?? (node.arguments[0]?.type === "TemplateLiteral" ? node.arguments[0].quasis.map((quasi) => quasi.value.cooked ?? "").join(" ") : null)
|
|
191
|
+
if (sql === null) return
|
|
192
|
+
if (!/^\s*(?:SELECT|WITH)\b/i.test(sql)) return
|
|
193
|
+
if (HAS_BOUND.test(sql) || AGGREGATE_ONLY.test(sql) || BY_PRIMARY_KEY.test(sql.trim().replace(/;$/, ""))) return
|
|
194
|
+
context.report({ node: node.arguments[0], messageId: "raw" })
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// -- R77 -----------------------------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
const LOOP_READS = new Set(["find", "findBy", "findOne", "findOneBy", "findOneOrFail", "findOneByOrFail", "findAndCount", "count", "countBy", "exists", "existsBy", "getOne", "getMany", "getRawMany", "getRawOne", "getCount", "query"])
|
|
204
|
+
const LOOP_RECEIVER = /^(?:manager|entityManager|em|repo|repository|dataSource|queryRunner|[a-z]\w*Repository|[a-z]\w*Repo|[a-z]\w*Store)$/
|
|
205
|
+
const ITERATION_METHODS = new Set(["map", "forEach", "flatMap", "filter", "reduce", "some", "every"])
|
|
206
|
+
const LOOP_STATEMENTS = new Set(["ForStatement", "ForOfStatement", "ForInStatement"])
|
|
207
|
+
|
|
208
|
+
/** Whether `node` runs once per element: inside a `for` loop, or inside a callback of an array iteration method. */
|
|
209
|
+
const insideIteration = (node) => {
|
|
210
|
+
let current = node.parent
|
|
211
|
+
while (current) {
|
|
212
|
+
if (LOOP_STATEMENTS.has(current.type)) return true
|
|
213
|
+
if (current.type === "FunctionDeclaration" || current.type === "FunctionExpression" || current.type === "ArrowFunctionExpression") {
|
|
214
|
+
const call = current.parent
|
|
215
|
+
return (
|
|
216
|
+
call?.type === "CallExpression"
|
|
217
|
+
&& call.callee.type === "MemberExpression"
|
|
218
|
+
&& !call.callee.computed
|
|
219
|
+
&& call.callee.property.type === "Identifier"
|
|
220
|
+
&& ITERATION_METHODS.has(call.callee.property.name)
|
|
221
|
+
&& call.arguments.includes(current)
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
current = current.parent
|
|
225
|
+
}
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A read inside an iteration is N+1: one round trip per element. */
|
|
230
|
+
export const noQueryInLoop = {
|
|
231
|
+
meta: {
|
|
232
|
+
type: "problem",
|
|
233
|
+
docs: { description: "A repository, entity-manager or query-builder read does not run once per element of a loop." },
|
|
234
|
+
schema: [],
|
|
235
|
+
messages: {
|
|
236
|
+
inLoop:
|
|
237
|
+
"`.{{method}}(...)` runs once per element, so the number of round trips grows with the input (N+1) and one large request holds a connection for as long as it takes. Read the rows once before the loop with `find({ where: { <key>: In(keys) }, take })` or `WHERE <key> = ANY($1)`, index them in a `Map`, and look up inside the loop. A polling `while` loop is not this rule's business.",
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
create(context) {
|
|
241
|
+
const filename = context.filename || context.getFilename()
|
|
242
|
+
if (isTestLane(filename) || isDeclarationFile(filename) || isMigrationPath(filename)) return {}
|
|
243
|
+
return {
|
|
244
|
+
CallExpression(node) {
|
|
245
|
+
const method = calleeProperty(node)
|
|
246
|
+
if (!method || !LOOP_READS.has(method)) return
|
|
247
|
+
const chain = chainNames(node.callee)
|
|
248
|
+
const builder = chain.includes("createQueryBuilder")
|
|
249
|
+
if (!builder && !LOOP_RECEIVER.test(receiverName(node.callee) ?? "")) return
|
|
250
|
+
if (method === "query") {
|
|
251
|
+
const first = node.arguments[0]
|
|
252
|
+
const sql = staticText(first) ?? (first?.type === "TemplateLiteral" ? first.quasis.map((quasi) => quasi.value.cooked ?? "").join(" ") : null)
|
|
253
|
+
if (sql === null || !/^\s*(?:SELECT|WITH)\b/i.test(sql)) return
|
|
254
|
+
}
|
|
255
|
+
if (!insideIteration(node)) return
|
|
256
|
+
context.report({ node: node.callee.property, messageId: "inLoop", data: { method } })
|
|
257
|
+
},
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The rules this law contributes to the plugin. */
|
|
263
|
+
export const rules = {
|
|
264
|
+
"no-interpolated-sql": noInterpolatedSql,
|
|
265
|
+
"query-needs-limit": queryNeedsLimit,
|
|
266
|
+
"no-query-in-loop": noQueryInLoop,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Both start at error: no baseline exists, and the repositories' fix lanes clear the debt. */
|
|
270
|
+
export const recommended = {
|
|
271
|
+
"starci-be/no-interpolated-sql": "error",
|
|
272
|
+
"starci-be/query-needs-limit": "error",
|
|
273
|
+
"starci-be/no-query-in-loop": "error",
|
|
274
|
+
}
|
package/resilience.mjs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules that keep one slow or malformed dependency from taking a process down.
|
|
3
|
+
*
|
|
4
|
+
* - `http-needs-timeout` (R70 `BE_HTTP_TIMEOUT`): an outbound HTTP call states how long it may take. A socket
|
|
5
|
+
* with no deadline holds a request, a connection and a worker slot for as long as the far side sleeps.
|
|
6
|
+
* - `json-parse-needs-guard` (R76 `BE_JSON_PARSE_UNGUARDED`): `JSON.parse` of text that came from outside the
|
|
7
|
+
* function sits inside a `try`. A corrupt row or a hostile payload then becomes a typed outcome instead of a 500.
|
|
8
|
+
* - `no-hand-rolled-retry` (R81 `BE_HAND_ROLLED_RETRY`): a loop that catches an error and waits before trying
|
|
9
|
+
* again re-implements retry by hand, outside `platform/retry`'s bounded, jittered, abortable helper.
|
|
10
|
+
*
|
|
11
|
+
* A call whose options are not an object literal cannot be judged from syntax, so it is left alone.
|
|
12
|
+
*/
|
|
13
|
+
import { keyName, walk } from "./lib/ast.mjs"
|
|
14
|
+
import { isDeclarationFile, isTestLane, normalizePath } from "./lib/path.mjs"
|
|
15
|
+
|
|
16
|
+
const HTTP_METHODS = new Set(["get", "post", "put", "patch", "delete", "head", "request"])
|
|
17
|
+
|
|
18
|
+
/** The receivers whose method calls are outbound HTTP, by the last name of the receiver. */
|
|
19
|
+
const HTTP_RECEIVER = /^(?:axios|http|httpService|httpClient|axiosInstance)$/
|
|
20
|
+
|
|
21
|
+
const lastName = (node) => {
|
|
22
|
+
if (node.type === "Identifier") return node.name
|
|
23
|
+
if (node.type === "MemberExpression" && !node.computed && node.property.type === "Identifier") return node.property.name
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const hasKey = (objectNode, names) =>
|
|
28
|
+
objectNode.properties.some((property) => property.type === "Property" && names.includes(keyName(property.key)))
|
|
29
|
+
|
|
30
|
+
const hasSpread = (objectNode) => objectNode.properties.some((property) => property.type === "SpreadElement")
|
|
31
|
+
|
|
32
|
+
/** Every outbound HTTP call carries a deadline. */
|
|
33
|
+
export const httpNeedsTimeout = {
|
|
34
|
+
meta: {
|
|
35
|
+
type: "problem",
|
|
36
|
+
docs: { description: "`fetch`, axios and HttpService calls state a `timeout` or an abort `signal`." },
|
|
37
|
+
schema: [],
|
|
38
|
+
messages: {
|
|
39
|
+
fetchNoSignal:
|
|
40
|
+
"`fetch` without a `signal`. A peer that accepts the connection and then goes quiet holds this request for as long as it likes. Pass `{ signal: AbortSignal.timeout(<named ms>) }`.",
|
|
41
|
+
clientNoTimeout:
|
|
42
|
+
"`{{call}}` states no `timeout` or `signal`. A peer that goes quiet holds this request, its socket and a worker slot indefinitely. Pass `{ timeout: <named ms> }` (or an abort `signal`) in the request config, or set it once on `axios.create({ timeout })`.",
|
|
43
|
+
createNoTimeout:
|
|
44
|
+
"`axios.create` without a `timeout`. Every call through this instance inherits no deadline. Set `timeout: <named ms>` here.",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
create(context) {
|
|
48
|
+
const filename = context.filename || context.getFilename()
|
|
49
|
+
if (isTestLane(filename) || isDeclarationFile(filename)) return {}
|
|
50
|
+
return {
|
|
51
|
+
CallExpression(node) {
|
|
52
|
+
const { callee } = node
|
|
53
|
+
if (callee.type === "Identifier" && callee.name === "fetch") {
|
|
54
|
+
const init = node.arguments[1]
|
|
55
|
+
if (!init) {
|
|
56
|
+
context.report({ node, messageId: "fetchNoSignal" })
|
|
57
|
+
} else if (init.type === "ObjectExpression" && !hasSpread(init) && !hasKey(init, ["signal"])) {
|
|
58
|
+
context.report({ node, messageId: "fetchNoSignal" })
|
|
59
|
+
}
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
if (callee.type !== "MemberExpression" || callee.computed || callee.property.type !== "Identifier") return
|
|
63
|
+
const method = callee.property.name
|
|
64
|
+
const receiver = lastName(callee.object)
|
|
65
|
+
if (receiver === "axios" && method === "create") {
|
|
66
|
+
const config = node.arguments[0]
|
|
67
|
+
if (!config || (config.type === "ObjectExpression" && !hasSpread(config) && !hasKey(config, ["timeout", "signal"]))) {
|
|
68
|
+
context.report({ node, messageId: "createNoTimeout" })
|
|
69
|
+
}
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
if (!HTTP_METHODS.has(method) || !receiver || !HTTP_RECEIVER.test(receiver)) return
|
|
73
|
+
// Node's own `http.get(url, callback)` is not this rule's business: a bare `http` receiver is not an injected client
|
|
74
|
+
if (receiver === "http" && callee.object.type !== "MemberExpression") return
|
|
75
|
+
const configAt = method === "request" ? 0 : ["post", "put", "patch"].includes(method) ? 2 : 1
|
|
76
|
+
const config = node.arguments[configAt]
|
|
77
|
+
if (config === undefined) {
|
|
78
|
+
context.report({ node, messageId: "clientNoTimeout", data: { call: `${receiver}.${method}` } })
|
|
79
|
+
} else if (config.type === "ObjectExpression" && !hasSpread(config) && !hasKey(config, ["timeout", "signal"])) {
|
|
80
|
+
context.report({ node, messageId: "clientNoTimeout", data: { call: `${receiver}.${method}` } })
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether `node` sits inside a `try` block of its own function. */
|
|
88
|
+
const insideTry = (node) => {
|
|
89
|
+
let child = node
|
|
90
|
+
let current = node.parent
|
|
91
|
+
while (current) {
|
|
92
|
+
if (current.type === "TryStatement" && current.block === child) return true
|
|
93
|
+
if (current.type === "FunctionDeclaration" || current.type === "FunctionExpression" || current.type === "ArrowFunctionExpression") return false
|
|
94
|
+
child = current
|
|
95
|
+
current = current.parent
|
|
96
|
+
}
|
|
97
|
+
return false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** `JSON.parse` of outside text fails inside a guard. */
|
|
101
|
+
export const jsonParseNeedsGuard = {
|
|
102
|
+
meta: {
|
|
103
|
+
type: "problem",
|
|
104
|
+
docs: { description: "`JSON.parse` sits inside a `try` in its own function." },
|
|
105
|
+
schema: [],
|
|
106
|
+
messages: {
|
|
107
|
+
unguarded:
|
|
108
|
+
"`JSON.parse` outside a `try`. Stored text, a webhook body and a provider reply can all be malformed, and the throw surfaces far from here as an unmasked 500. Wrap it and return a typed outcome (or a `DomainError`) naming what was being parsed.",
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
create(context) {
|
|
112
|
+
const filename = context.filename || context.getFilename()
|
|
113
|
+
if (isTestLane(filename) || isDeclarationFile(filename)) return {}
|
|
114
|
+
return {
|
|
115
|
+
CallExpression(node) {
|
|
116
|
+
const { callee } = node
|
|
117
|
+
if (callee.type !== "MemberExpression" || callee.computed) return
|
|
118
|
+
if (callee.object.type !== "Identifier" || callee.object.name !== "JSON") return
|
|
119
|
+
if (callee.property.type !== "Identifier" || callee.property.name !== "parse") return
|
|
120
|
+
if (insideTry(node)) return
|
|
121
|
+
context.report({ node, messageId: "unguarded" })
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const PLATFORM_RETRY = /\/src\/modules\/platform\/retry\//
|
|
128
|
+
const DELAY_NAMES = /^(?:sleep|delay|wait|backoff)$/i
|
|
129
|
+
const LOOP_TYPES = new Set(["ForStatement", "WhileStatement", "DoWhileStatement"])
|
|
130
|
+
|
|
131
|
+
/** Whether `node` is a call shaped like a delay: `sleep(ms)`, `x.delay(ms)`, or `setTimeout(...)`. */
|
|
132
|
+
const isDelayCall = (node) => {
|
|
133
|
+
if (node.type !== "CallExpression") return false
|
|
134
|
+
const { callee } = node
|
|
135
|
+
if (callee.type === "Identifier") return DELAY_NAMES.test(callee.name) || callee.name === "setTimeout"
|
|
136
|
+
return callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && DELAY_NAMES.test(callee.property.name)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A loop that both catches an error and waits is retrying by hand. */
|
|
140
|
+
export const noHandRolledRetry = {
|
|
141
|
+
meta: {
|
|
142
|
+
type: "problem",
|
|
143
|
+
docs: { description: "A loop that catches an error and waits before trying again goes through `platform/retry`, not a hand-written loop." },
|
|
144
|
+
schema: [],
|
|
145
|
+
messages: {
|
|
146
|
+
handRolled:
|
|
147
|
+
"This loop catches an error and waits before trying again - a hand-rolled retry with no attempt bound visible here, no jitter and no way to honor an abort signal. Retry through the shared `platform/retry` helper (bounded attempts, exponential backoff with jitter, an abort signal) instead of a loop written at the call site.",
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
create(context) {
|
|
151
|
+
const filename = normalizePath(context.filename || context.getFilename())
|
|
152
|
+
if (isTestLane(filename) || isDeclarationFile(filename) || PLATFORM_RETRY.test(filename)) return {}
|
|
153
|
+
const check = (node) => {
|
|
154
|
+
let hasCatch = false
|
|
155
|
+
let hasDelay = false
|
|
156
|
+
walk(node.body, (child) => {
|
|
157
|
+
if (child.type === "CatchClause") hasCatch = true
|
|
158
|
+
else if (isDelayCall(child)) hasDelay = true
|
|
159
|
+
}, { intoFunctions: false })
|
|
160
|
+
if (hasCatch && hasDelay) context.report({ node, messageId: "handRolled" })
|
|
161
|
+
}
|
|
162
|
+
return Object.fromEntries([...LOOP_TYPES].map((type) => [type, check]))
|
|
163
|
+
},
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The rules this law contributes to the plugin. */
|
|
167
|
+
export const rules = {
|
|
168
|
+
"http-needs-timeout": httpNeedsTimeout,
|
|
169
|
+
"json-parse-needs-guard": jsonParseNeedsGuard,
|
|
170
|
+
"no-hand-rolled-retry": noHandRolledRetry,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** All three start at error: no baseline exists, and the repositories' fix lanes clear the debt. */
|
|
174
|
+
export const recommended = {
|
|
175
|
+
"starci-be/http-needs-timeout": "error",
|
|
176
|
+
"starci-be/json-parse-needs-guard": "error",
|
|
177
|
+
"starci-be/no-hand-rolled-retry": "error",
|
|
178
|
+
}
|