@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/eslint-canon-be",
3
- "version": "1.2.2",
3
+ "version": "1.7.0",
4
4
  "description": "The StarCi back-end lint canon: every rule its laws publish, gathered as one flat-config plugin.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -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
+ }