@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +95 -5
  2. package/README.md +41 -52
  3. package/async-discipline.mjs +2 -2
  4. package/authorization.mjs +38 -88
  5. package/builders.mjs +305 -0
  6. package/comments.mjs +19 -34
  7. package/config-owner.mjs +127 -125
  8. package/connections.mjs +254 -0
  9. package/cqrs.mjs +426 -135
  10. package/data-access.mjs +320 -391
  11. package/default-deny.mjs +14 -10
  12. package/e2e-flow.mjs +208 -180
  13. package/error-handling.mjs +254 -128
  14. package/idempotency.mjs +98 -67
  15. package/index.mjs +32 -9
  16. package/infra-owner.mjs +96 -0
  17. package/injection.mjs +602 -0
  18. package/input-bounds.mjs +166 -66
  19. package/lib/config.mjs +70 -33
  20. package/lib/db-writes.mjs +58 -0
  21. package/lib/declared.mjs +154 -0
  22. package/lib/hfs.mjs +61 -0
  23. package/lib/import-source.mjs +104 -0
  24. package/lib/persistence.mjs +160 -0
  25. package/lib/ports.mjs +138 -0
  26. package/lib/transport-slots.mjs +14 -0
  27. package/lib/types.mjs +133 -0
  28. package/lib/unit-spec.mjs +33 -0
  29. package/log-safety.mjs +94 -26
  30. package/module-layering.mjs +180 -297
  31. package/module-shape.mjs +296 -59
  32. package/naming.mjs +44 -12
  33. package/observability.mjs +57 -91
  34. package/operations.mjs +201 -0
  35. package/package.json +10 -7
  36. package/query-safety.mjs +189 -223
  37. package/resilience.mjs +126 -31
  38. package/runtime/engine/plain-object.mjs +5 -0
  39. package/runtime/engine/runtime-root.mjs +32 -0
  40. package/runtime/engine/yaml.mjs +161 -0
  41. package/runtime/knowledge/hfs/slots.yaml +1111 -0
  42. package/runtime/scripts/lib/git.mjs +53 -0
  43. package/runtime/scripts/lib/glob.mjs +23 -0
  44. package/runtime/scripts/lib/hfs-allows.mjs +57 -0
  45. package/runtime/scripts/lib/hfs-slots.mjs +729 -0
  46. package/runtime/scripts/lib/hfs-view.mjs +68 -0
  47. package/runtime/scripts/lib/language.mjs +107 -0
  48. package/runtime/scripts/lib/path-key.mjs +17 -0
  49. package/runtime/scripts/lib/recorded-lines.mjs +27 -0
  50. package/schema-authority.mjs +273 -91
  51. package/size-budget.mjs +19 -61
  52. package/spec-no-skip.mjs +84 -0
  53. package/spec-quality.mjs +76 -35
  54. package/suppression.mjs +34 -16
  55. package/temporal.mjs +101 -36
  56. package/test-world.mjs +204 -0
  57. package/testing.mjs +99 -251
  58. package/transport.mjs +433 -142
  59. package/type-safety.mjs +50 -278
  60. package/unit-spec.mjs +452 -0
  61. package/user-copy.mjs +56 -37
  62. package/lib/slots.mjs +0 -83
package/query-safety.mjs CHANGED
@@ -1,274 +1,240 @@
1
1
  /**
2
- * The rules that keep a database call safe to run in production.
2
+ * The rules that keep a database call safe to run in production (R36, R68, R69, R77).
3
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.
4
+ * SQL text is a `SqlText` constant built with the `sql` tag in a persistence `<name>.sql.ts`; a dynamic identifier is a
5
+ * `SqlIdent` from `ident()`; a value is a numbered parameter. Every receiver is identified by its TypeScript type
6
+ * (`EntityManager`, `QueryRunner`, `DataSource` from `typeorm`), never by its name, and every path question goes to the
7
+ * HFS slot view. No rule here parses SQL text: what a statement reads and writes is judged by the architecture
8
+ * machine (R86).
8
9
  *
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.
10
+ * - `sql-text-only` (R36 `BE_SQL_OUTSIDE_PERSISTENCE`) - the first argument of `.query(` is a `SqlText`, and the `sql`
11
+ * tag is called only in a persistence `<name>.sql.ts`.
12
+ * - `no-query-builder` (R36) - no `createQueryBuilder(` call.
13
+ * - `no-interpolated-sql` (R68 `BE_SQL_INTERPOLATED`) - a substitution inside the `sql` tag is a `SqlIdent`; a `.query(`
14
+ * string built by template or concatenation is refused.
15
+ * - `query-needs-limit` (R69 `BE_QUERY_UNBOUNDED`) - `find`, `findBy` and `findAndCount` on an `EntityManager` carry `take`.
16
+ * - `no-query-in-loop` (R77 `BE_QUERY_IN_LOOP`) - an `EntityManager` read does not run once per element of a loop.
17
+ *
18
+ * A migration is hand-written DDL run once by `apps/migrate`; its `queryRunner.query(...)` takes plain text.
12
19
  */
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
+ import { keyName } from "./lib/ast.mjs"
21
+ import { hfsOf } from "./lib/hfs.mjs"
22
+ import { baseNameOf, infraTypeOf, isMigrationFile } from "./lib/persistence.mjs"
23
+ import { isPackageType, typeOrigins } from "./lib/types.mjs"
20
24
 
21
25
  /** The property a call goes through: `manager.query(...)` gives `query`. */
22
26
  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
27
+ node.callee.type === "MemberExpression" && !node.callee.computed && node.callee.property.type === "Identifier"
28
+ ? node.callee.property.name
29
+ : null
30
+
31
+ /** Whether a type origin is declared by the platform database capability (where `SqlText`, `SqlIdent` and `sql` live). */
32
+ const fromDatabaseCapability = (hfs, origin) => {
33
+ const found = hfs.classify(origin.file)
34
+ return found.slot === "be.platform" && found.bindings?.capability === "database"
33
35
  }
34
36
 
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"])
37
+ /** Whether the value at `node` has the branded type `name` exported by the platform database capability. */
38
+ const hasBrand = (context, hfs, node, name) => typeOrigins(context, node).some((origin) => origin.name === name && fromDatabaseCapability(hfs, origin))
39
+
40
+ /** Whether a call goes through `.query` on a manager, runner or data source. */
41
+ const isQueryCall = (context, node) => calleeProperty(node) === "query" && infraTypeOf(context, node.callee.object) !== null
42
+
43
+ // -- R36 -----------------------------------------------------------------------------------------------
44
+
45
+ /** SQL text is `SqlText`, and only a persistence `.sql.ts` builds it. */
46
+ export const sqlTextOnly = {
47
+ meta: {
48
+ type: "problem",
49
+ docs: { description: "`.query(...)` takes a `SqlText`, and the `sql` tag is called only in a persistence `<name>.sql.ts`." },
50
+ schema: [],
51
+ messages: {
52
+ notSqlText:
53
+ "The first argument of `.query(...)` is not a `SqlText`. SQL text is a constant built with the `sql` tag in the `persistence/<name>.sql.ts` of the owning capability and imported here; the row shape and its mapper live in `<name>.rows.ts`.",
54
+ tagOutsideSqlFile:
55
+ "The `sql` tag is called outside a persistence `<name>.sql.ts`. Write the statement as an exported constant in the `persistence/<name>.sql.ts` of the capability that owns the tables, and import it.",
56
+ },
57
+ },
58
+ create(context) {
59
+ const hfs = hfsOf(context)
60
+ const filename = context.filename || context.getFilename()
61
+ const migration = isMigrationFile(hfs, filename)
62
+ const sqlFile = hfs.slotOf(filename) === "be.persistence" && baseNameOf(filename).endsWith(".sql.ts")
63
+ const isSqlTag = (node) => typeOrigins(context, node).some((origin) => origin.name === "sql" && fromDatabaseCapability(hfs, origin))
64
+ return {
65
+ CallExpression(node) {
66
+ if (isSqlTag(node.callee) && !sqlFile) context.report({ node, messageId: "tagOutsideSqlFile" })
67
+ if (migration || !isQueryCall(context, node)) return
68
+ const first = node.arguments[0]
69
+ if (!first || !hasBrand(context, hfs, first, "SqlText")) context.report({ node: first ?? node, messageId: "notSqlText" })
70
+ },
71
+ TaggedTemplateExpression(node) {
72
+ if (isSqlTag(node.tag) && !sqlFile) context.report({ node, messageId: "tagOutsideSqlFile" })
73
+ },
74
+ }
75
+ },
76
+ }
40
77
 
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
78
+ /** There is one way to write a complex read, and it is SQL. */
79
+ export const noQueryBuilder = {
80
+ meta: {
81
+ type: "problem",
82
+ docs: { description: "No `createQueryBuilder(...)` call." },
83
+ schema: [],
84
+ messages: {
85
+ builder:
86
+ "`createQueryBuilder(...)` is banned. A read is `manager.find(Entity, { where, take })`, or a `SqlText` constant in the capability's `persistence/<name>.sql.ts` run with `manager.query(...)`.",
87
+ },
88
+ },
89
+ create(context) {
90
+ return {
91
+ CallExpression(node) {
92
+ const callee = node.callee
93
+ const name = callee.type === "Identifier" ? callee.name : calleeProperty(node)
94
+ if (name === "createQueryBuilder") context.report({ node, messageId: "builder" })
95
+ },
96
+ }
97
+ },
55
98
  }
56
99
 
57
100
  // -- R68 -----------------------------------------------------------------------------------------------
58
101
 
59
102
  /** A statement is text plus parameters; a value never becomes part of the text. */
60
103
  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.",
104
+ meta: {
105
+ type: "problem",
106
+ docs: { description: "Inside the `sql` tag every substitution is a `SqlIdent`; a `.query(...)` string is never built by template or concatenation." },
107
+ schema: [],
108
+ messages: {
109
+ substitution:
110
+ "This substitution (`{{expression}}`) is not a `SqlIdent`. A value spliced into a statement is an injection hole and defeats the plan cache. Pass a value as a numbered parameter (`$1`) in the array argument; a dynamic identifier goes through `ident(name, allowed)`, which checks it against a fixed list and is the only substitution the `sql` tag accepts.",
111
+ built:
112
+ "This SQL text is built with a template or a `+`. A value spliced into a statement is an injection hole. Write the statement as a `SqlText` constant with the `sql` tag in `persistence/<name>.sql.ts` and pass values as numbered parameters (`$1`) in the array argument.",
113
+ },
70
114
  },
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" })
115
+ create(context) {
116
+ const hfs = hfsOf(context)
117
+ const sourceCode = context.sourceCode || context.getSourceCode()
118
+ const filename = context.filename || context.getFilename()
119
+ const migration = isMigrationFile(hfs, filename)
120
+ return {
121
+ TaggedTemplateExpression(node) {
122
+ if (!typeOrigins(context, node.tag).some((origin) => origin.name === "sql" && fromDatabaseCapability(hfs, origin))) return
123
+ for (const expression of node.quasi.expressions) {
124
+ if (!hasBrand(context, hfs, expression, "SqlIdent")) {
125
+ context.report({ node: expression, messageId: "substitution", data: { expression: sourceCode.getText(expression).slice(0, 60) } })
126
+ }
127
+ }
128
+ },
129
+ CallExpression(node) {
130
+ if (migration || !isQueryCall(context, node)) return
131
+ const first = node.arguments[0]
132
+ if (first?.type === "TemplateLiteral" && first.expressions.length > 0) context.report({ node: first, messageId: "built" })
133
+ else if (first?.type === "BinaryExpression" && first.operator === "+") context.report({ node: first, messageId: "built" })
134
+ },
95
135
  }
96
- },
97
- }
98
- },
136
+ },
99
137
  }
100
138
 
101
139
  // -- R69 -----------------------------------------------------------------------------------------------
102
140
 
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
- }
141
+ const BOUNDED_READS = new Set(["find", "findBy", "findAndCount", "findAndCountBy"])
140
142
 
141
143
  /** A list read states its bound. */
142
144
  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.",
145
+ meta: {
146
+ type: "problem",
147
+ docs: { description: "`find`, `findBy` and `findAndCount` on an `EntityManager` state `take`." },
148
+ schema: [],
149
+ messages: {
150
+ find:
151
+ "`.{{method}}(...)` reads every matching row and states no `take` in its options object. Pass `take` with a named bound (`manager.find(Entity, { where, take: LIST_ROWS_MAX })`), or page by keyset.",
152
+ findBy:
153
+ "`.{{method}}(...)` cannot take a bound, so it reads every matching row. Use `manager.find(Entity, { where, take })` with a named bound, or page by keyset.",
154
+ },
156
155
  },
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
156
+ create(context) {
157
+ return {
158
+ CallExpression(node) {
159
+ const method = calleeProperty(node)
160
+ if (!method || !BOUNDED_READS.has(method) || !isPackageType(context, node.callee.object, "EntityManager", "typeorm")) return
161
+ if (method === "findBy" || method === "findAndCountBy") {
162
+ context.report({ node: node.callee.property, messageId: "findBy", data: { method } })
163
+ return
164
+ }
165
+ const options = node.arguments[node.arguments.length - 1]
166
+ const hasTake =
167
+ options?.type === "ObjectExpression"
168
+ && options.properties.some((property) => property.type === "Property" && keyName(property.key) === "take")
169
+ if (!hasTake) context.report({ node: node.callee.property, messageId: "find", data: { method } })
170
+ },
187
171
  }
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
- },
172
+ },
199
173
  }
200
174
 
201
175
  // -- R77 -----------------------------------------------------------------------------------------------
202
176
 
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)$/
177
+ const LOOP_READS = new Set(["find", "findBy", "findOne", "findOneBy", "findOneOrFail", "findOneByOrFail", "findAndCount", "findAndCountBy", "count", "countBy", "exists", "existsBy"])
205
178
  const ITERATION_METHODS = new Set(["map", "forEach", "flatMap", "filter", "reduce", "some", "every"])
206
179
  const LOOP_STATEMENTS = new Set(["ForStatement", "ForOfStatement", "ForInStatement"])
207
180
 
208
181
  /** Whether `node` runs once per element: inside a `for` loop, or inside a callback of an array iteration method. */
209
182
  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
- )
183
+ let current = node.parent
184
+ while (current) {
185
+ if (LOOP_STATEMENTS.has(current.type)) return true
186
+ if (current.type === "FunctionDeclaration" || current.type === "FunctionExpression" || current.type === "ArrowFunctionExpression") {
187
+ const call = current.parent
188
+ return (
189
+ call?.type === "CallExpression"
190
+ && call.callee.type === "MemberExpression"
191
+ && !call.callee.computed
192
+ && call.callee.property.type === "Identifier"
193
+ && ITERATION_METHODS.has(call.callee.property.name)
194
+ && call.arguments.includes(current)
195
+ )
196
+ }
197
+ current = current.parent
223
198
  }
224
- current = current.parent
225
- }
226
- return false
199
+ return false
227
200
  }
228
201
 
229
202
  /** A read inside an iteration is N+1: one round trip per element. */
230
203
  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.",
204
+ meta: {
205
+ type: "problem",
206
+ docs: { description: "An `EntityManager` read does not run once per element of a loop." },
207
+ schema: [],
208
+ messages: {
209
+ inLoop:
210
+ "`.{{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(Entity, { where: { <key>: In(keys) }, take })` or a `SqlText` with `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.",
211
+ },
238
212
  },
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
213
+ create(context) {
214
+ return {
215
+ CallExpression(node) {
216
+ const method = calleeProperty(node)
217
+ if (!method || !LOOP_READS.has(method) || !insideIteration(node)) return
218
+ if (infraTypeOf(context, node.callee.object) === "EntityManager") context.report({ node: node.callee.property, messageId: "inLoop", data: { method } })
219
+ },
254
220
  }
255
- if (!insideIteration(node)) return
256
- context.report({ node: node.callee.property, messageId: "inLoop", data: { method } })
257
- },
258
- }
259
- },
221
+ },
260
222
  }
261
223
 
262
224
  /** The rules this law contributes to the plugin. */
263
225
  export const rules = {
264
- "no-interpolated-sql": noInterpolatedSql,
265
- "query-needs-limit": queryNeedsLimit,
266
- "no-query-in-loop": noQueryInLoop,
226
+ "sql-text-only": sqlTextOnly,
227
+ "no-query-builder": noQueryBuilder,
228
+ "no-interpolated-sql": noInterpolatedSql,
229
+ "query-needs-limit": queryNeedsLimit,
230
+ "no-query-in-loop": noQueryInLoop,
267
231
  }
268
232
 
269
- /** Both start at error: no baseline exists, and the repositories' fix lanes clear the debt. */
233
+ /** Every rule of this law ships at `error`; no baseline exists. */
270
234
  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",
235
+ "starci-be/sql-text-only": "error",
236
+ "starci-be/no-query-builder": "error",
237
+ "starci-be/no-interpolated-sql": "error",
238
+ "starci-be/query-needs-limit": "error",
239
+ "starci-be/no-query-in-loop": "error",
274
240
  }