querydig 0.0.0-stage → 0.1.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,1208 @@
1
+ // src/rules.ts
2
+ var r = (m) => m;
3
+ var RULES = [
4
+ // ---------- generic (any database) ----------
5
+ r({
6
+ id: "QD001",
7
+ name: "n-plus-one",
8
+ title: "Query inside a loop (N+1)",
9
+ severity: "high",
10
+ db: "any",
11
+ why: "Each iteration sends a separate round-trip to the database. With N items you run N+1 queries; latency grows linearly with data size and connection pools get exhausted.",
12
+ fix: "Fetch everything in one query ($in / WHERE id IN (...) / include / populate / select_related / Preload) and join in memory with a Map keyed by id.",
13
+ example: {
14
+ bad: "for (const o of orders) {\n o.user = await User.findById(o.userId);\n}",
15
+ good: "const users = await User.find({ _id: { $in: orders.map(o => o.userId) } }).lean();\nconst byId = new Map(users.map(u => [String(u._id), u]));\nfor (const o of orders) o.user = byId.get(String(o.userId));"
16
+ }
17
+ }),
18
+ r({
19
+ id: "QD002",
20
+ name: "nested-query-over-results",
21
+ title: "Nested query over another query's results (N\xB2 risk)",
22
+ severity: "critical",
23
+ db: "any",
24
+ why: "The loop iterates the rows returned by an earlier query and issues another query per row. Cost multiplies with every level of nesting and is usually the #1 cause of slow endpoints.",
25
+ fix: "Replace with a single aggregation ($lookup) / JOIN, or batch the inner query with $in / IN (...) using the ids collected from the outer result.",
26
+ example: {
27
+ bad: "const users = await User.find({ active: true });\nfor (const u of users) {\n u.orders = await Order.find({ userId: u._id });\n}",
28
+ good: "const users = await User.find({ active: true }).lean();\nconst orders = await Order.find({ userId: { $in: users.map(u => u._id) } }).lean();\n// group orders by userId in memory"
29
+ }
30
+ }),
31
+ r({
32
+ id: "QD003",
33
+ name: "write-in-loop",
34
+ title: "Write operation inside a loop",
35
+ severity: "high",
36
+ db: "any",
37
+ why: "One insert/update/delete per iteration means N round-trips and N separate index updates/journal flushes.",
38
+ fix: "Use bulk APIs: bulkWrite / insertMany / updateMany (MongoDB), createMany / bulkCreate / multi-row INSERT / executemany / CreateInBatches (SQL).",
39
+ example: {
40
+ bad: "for (const item of items) {\n await Item.updateOne({ _id: item.id }, { $set: { price: item.price } });\n}",
41
+ good: "await Item.bulkWrite(items.map(i => ({\n updateOne: { filter: { _id: i.id }, update: { $set: { price: i.price } } },\n})));"
42
+ }
43
+ }),
44
+ // ---------- MongoDB ----------
45
+ r({
46
+ id: "MQ001",
47
+ name: "unbounded-find",
48
+ title: "Unbounded find with empty filter",
49
+ severity: "high",
50
+ db: "mongodb",
51
+ why: "find({}) without limit reads the entire collection (COLLSCAN) and transfers every document to the app. It gets slower every day the collection grows.",
52
+ fix: "Add a selective filter backed by an index, a .limit(), and paginate with a range query on an indexed field (e.g. _id > lastId).",
53
+ example: { bad: "const all = await Product.find({});", good: "const page = await Product.find({ _id: { $gt: lastId } }).sort({ _id: 1 }).limit(50).lean();" }
54
+ }),
55
+ r({
56
+ id: "MQ002",
57
+ name: "unanchored-regex",
58
+ title: "Unanchored or case-insensitive $regex",
59
+ severity: "high",
60
+ db: "mongodb",
61
+ why: "Only prefix-anchored, case-sensitive regexes (/^abc/) can use an index efficiently. /abc/ or /abc/i scans every index key or document.",
62
+ fix: "Anchor the pattern with ^, store a lower-cased copy of the field for case-insensitive matching, or use a text / Atlas Search index.",
63
+ example: { bad: "User.find({ name: { $regex: search, $options: 'i' } })", good: "User.find({ nameLower: { $regex: '^' + escape(search.toLowerCase()) } })" }
64
+ }),
65
+ r({
66
+ id: "MQ003",
67
+ name: "server-side-js",
68
+ title: "$where / $function / mapReduce (JavaScript execution)",
69
+ severity: "high",
70
+ db: "mongodb",
71
+ why: "Server-side JavaScript cannot use indexes, runs per document and is deprecated in recent MongoDB versions.",
72
+ fix: "Rewrite with standard query operators ($expr) or the aggregation pipeline."
73
+ }),
74
+ r({
75
+ id: "MQ004",
76
+ name: "skip-pagination",
77
+ title: "Pagination with skip()",
78
+ severity: "medium",
79
+ db: "mongodb",
80
+ why: "skip(n) still walks n documents/index keys before returning results; deep pages get linearly slower.",
81
+ fix: "Use range (keyset) pagination: filter on an indexed field greater than the last seen value and .limit(pageSize)."
82
+ }),
83
+ r({
84
+ id: "MQ005",
85
+ name: "sort-without-limit",
86
+ title: "sort() without limit()",
87
+ severity: "medium",
88
+ db: "mongodb",
89
+ why: "Sorting a large result without a limit (and without a matching index) triggers a blocking in-memory SORT that can hit the 100MB limit.",
90
+ fix: "Add .limit() and create an index whose key order matches the sort (ESR rule: Equality, Sort, Range)."
91
+ }),
92
+ r({
93
+ id: "MQ006",
94
+ name: "lookup-before-match",
95
+ title: "Aggregation: $lookup/$unwind/$group before $match",
96
+ severity: "medium",
97
+ db: "mongodb",
98
+ why: "Stages before the first $match process every document in the collection. $match placed first can use an index and shrinks the working set.",
99
+ fix: "Move $match (and $limit when possible) to the start of the pipeline; filter before $lookup and $unwind."
100
+ }),
101
+ r({
102
+ id: "MQ007",
103
+ name: "deep-populate",
104
+ title: "Deep or chained populate()",
105
+ severity: "medium",
106
+ db: "mongodb",
107
+ why: "Every populate path is an extra query; nested populate multiplies them and loads full referenced documents.",
108
+ fix: "Populate only needed paths with select, or replace with a single $lookup aggregation."
109
+ }),
110
+ r({
111
+ id: "MQ008",
112
+ name: "count-for-existence",
113
+ title: "countDocuments() used as existence check",
114
+ severity: "low",
115
+ db: "mongodb",
116
+ why: "countDocuments counts all matches; you only need to know whether one exists.",
117
+ fix: "Use findOne(filter, { _id: 1 }) / exists() / find(filter).limit(1)."
118
+ }),
119
+ r({
120
+ id: "MQ009",
121
+ name: "negation-operators",
122
+ title: "Negation operators ($ne / $nin / $not)",
123
+ severity: "low",
124
+ db: "mongodb",
125
+ why: "Negations are not selective; the planner usually has to scan most of the index or collection.",
126
+ fix: "Rewrite as a positive match ($in of allowed values) or combine with a selective equality filter."
127
+ }),
128
+ r({
129
+ id: "MQ010",
130
+ name: "no-projection",
131
+ title: "find() without projection",
132
+ severity: "low",
133
+ db: "mongodb",
134
+ why: "Fetching whole documents increases network transfer, memory and deserialization cost, and prevents covered queries.",
135
+ fix: 'Select only the fields you need: .select("a b") / { projection: { a: 1, b: 1 } } / SetProjection().'
136
+ }),
137
+ r({
138
+ id: "MQ011",
139
+ name: "missing-lean",
140
+ title: "Mongoose read without .lean()",
141
+ severity: "low",
142
+ db: "mongodb",
143
+ why: "Hydrating full Mongoose documents is 3-5x slower and uses more memory than plain objects for read-only paths.",
144
+ fix: "Append .lean() to read-only queries."
145
+ }),
146
+ // ---------- SQL ----------
147
+ r({
148
+ id: "SQ001",
149
+ name: "unbounded-orm-read",
150
+ title: "Unbounded ORM read (no where / limit)",
151
+ severity: "high",
152
+ db: "sql",
153
+ why: "findMany()/findAll()/objects.all() without a filter or limit loads the whole table into memory.",
154
+ fix: "Add a where clause and take/limit with cursor-based pagination."
155
+ }),
156
+ r({
157
+ id: "SQ002",
158
+ name: "select-star",
159
+ title: "SELECT *",
160
+ severity: "low",
161
+ db: "sql",
162
+ why: "Reads every column (including large TEXT/JSON), prevents index-only scans and breaks when columns change.",
163
+ fix: "List only the columns you need."
164
+ }),
165
+ r({
166
+ id: "SQ003",
167
+ name: "leading-wildcard-like",
168
+ title: "LIKE with leading wildcard ('%...')",
169
+ severity: "high",
170
+ db: "sql",
171
+ why: "A B-tree index cannot be used for LIKE '%term'; the database scans the whole table.",
172
+ fix: "Use prefix match (LIKE 'term%'), a trigram (pg_trgm) / FULLTEXT index, or a search engine."
173
+ }),
174
+ r({
175
+ id: "SQ004",
176
+ name: "missing-where",
177
+ title: "UPDATE/DELETE without WHERE or unbounded SELECT",
178
+ severity: "high",
179
+ db: "sql",
180
+ why: "Touches every row in the table: full scan, heavy locking and replication lag.",
181
+ fix: "Add a WHERE clause on an indexed column, a LIMIT, or batch the operation."
182
+ }),
183
+ r({
184
+ id: "SQ005",
185
+ name: "offset-pagination",
186
+ title: "OFFSET pagination",
187
+ severity: "medium",
188
+ db: "sql",
189
+ why: "OFFSET n reads and discards n rows; deep pages get linearly slower.",
190
+ fix: "Use keyset pagination: WHERE id > :lastId ORDER BY id LIMIT :n."
191
+ }),
192
+ r({
193
+ id: "SQ006",
194
+ name: "function-on-column",
195
+ title: "Function applied to column in WHERE",
196
+ severity: "medium",
197
+ db: "sql",
198
+ why: "WHERE LOWER(col) = ... or DATE(col) = ... makes the condition non-sargable; a normal index on col cannot be used.",
199
+ fix: "Compare the raw column against a transformed value/range, or create an expression index."
200
+ }),
201
+ r({
202
+ id: "SQ007",
203
+ name: "order-by-random",
204
+ title: "ORDER BY RAND()/RANDOM()",
205
+ severity: "high",
206
+ db: "sql",
207
+ why: "Generates a random value for every row and sorts the whole table.",
208
+ fix: "Pick random ids in the app, use TABLESAMPLE, or a precomputed random column with an index."
209
+ }),
210
+ r({
211
+ id: "SQ008",
212
+ name: "not-in-subquery",
213
+ title: "NOT IN (subquery)",
214
+ severity: "medium",
215
+ db: "sql",
216
+ why: "NOT IN with a subquery is often executed as a nested loop and has surprising NULL semantics.",
217
+ fix: "Rewrite as NOT EXISTS or a LEFT JOIN ... WHERE x IS NULL."
218
+ })
219
+ ];
220
+ var RULE_MAP = new Map(RULES.map((x) => [x.id, x]));
221
+ function findRule(idOrName) {
222
+ const q = idOrName.toUpperCase();
223
+ return RULE_MAP.get(q) ?? RULES.find((x) => x.name === idOrName.toLowerCase());
224
+ }
225
+
226
+ // src/analyzer.ts
227
+ import { createHash } from "crypto";
228
+
229
+ // src/languages.ts
230
+ var JS_BASE = {
231
+ call: ["call_expression"],
232
+ member: { member_expression: ["object", "property"] },
233
+ loops: ["for_statement", "for_in_statement", "while_statement", "do_statement"],
234
+ functions: ["function_declaration", "method_definition", "generator_function_declaration", "class_declaration"],
235
+ lambdas: ["arrow_function", "function_expression", "function"],
236
+ strings: ["string", "template_string"],
237
+ regex: "regex",
238
+ iterMethods: ["map", "forEach", "flatMap", "reduce", "filter", "some", "every", "each", "mapSeries", "eachSeries", "mapLimit", "eachLimit", "forEachOf", "times"]
239
+ };
240
+ var LANGS = {
241
+ javascript: { id: "javascript", grammar: "tree-sitter-javascript.wasm", ...JS_BASE },
242
+ typescript: { id: "typescript", grammar: "tree-sitter-typescript.wasm", ...JS_BASE },
243
+ tsx: { id: "tsx", grammar: "tree-sitter-tsx.wasm", ...JS_BASE },
244
+ python: {
245
+ id: "python",
246
+ grammar: "tree-sitter-python.wasm",
247
+ call: ["call"],
248
+ member: { attribute: ["object", "attribute"] },
249
+ loops: ["for_statement", "while_statement", "list_comprehension", "set_comprehension", "dictionary_comprehension", "generator_expression"],
250
+ functions: ["function_definition", "class_definition"],
251
+ lambdas: ["lambda"],
252
+ strings: ["string"],
253
+ iterMethods: []
254
+ },
255
+ go: {
256
+ id: "go",
257
+ grammar: "tree-sitter-go.wasm",
258
+ call: ["call_expression"],
259
+ member: { selector_expression: ["operand", "field"] },
260
+ loops: ["for_statement"],
261
+ functions: ["function_declaration", "method_declaration"],
262
+ lambdas: ["func_literal"],
263
+ strings: ["interpreted_string_literal", "raw_string_literal"],
264
+ iterMethods: []
265
+ }
266
+ };
267
+ var EXT = {
268
+ ".js": "javascript",
269
+ ".mjs": "javascript",
270
+ ".cjs": "javascript",
271
+ ".jsx": "tsx",
272
+ ".ts": "typescript",
273
+ ".mts": "typescript",
274
+ ".cts": "typescript",
275
+ ".tsx": "tsx",
276
+ ".py": "python",
277
+ ".go": "go"
278
+ };
279
+ function langForFile(file) {
280
+ const i = file.lastIndexOf(".");
281
+ if (i < 0) return void 0;
282
+ if (file.endsWith(".d.ts")) return void 0;
283
+ return EXT[file.slice(i).toLowerCase()];
284
+ }
285
+ var SUPPORTED_EXTENSIONS = Object.keys(EXT);
286
+
287
+ // src/parser.ts
288
+ import { createRequire } from "module";
289
+ import { existsSync } from "fs";
290
+ import { dirname, join } from "path";
291
+ import { fileURLToPath } from "url";
292
+ var require2 = createRequire(import.meta.url);
293
+ var Parser = require2("web-tree-sitter");
294
+ function grammarDir() {
295
+ let dir = dirname(fileURLToPath(import.meta.url));
296
+ for (let i = 0; i < 4; i++) {
297
+ const candidate = join(dir, "grammars");
298
+ if (existsSync(candidate)) return candidate;
299
+ dir = dirname(dir);
300
+ }
301
+ throw new Error("querydig: grammars directory not found");
302
+ }
303
+ var initPromise = null;
304
+ var languages = /* @__PURE__ */ new Map();
305
+ var parsers = /* @__PURE__ */ new Map();
306
+ function initParser() {
307
+ if (!initPromise) initPromise = Parser.init();
308
+ return initPromise;
309
+ }
310
+ async function getParser(lang) {
311
+ await initParser();
312
+ let p = parsers.get(lang);
313
+ if (p) return p;
314
+ let language = languages.get(lang);
315
+ if (!language) {
316
+ language = await Parser.Language.load(join(grammarDir(), LANGS[lang].grammar));
317
+ languages.set(lang, language);
318
+ }
319
+ const parser = new Parser();
320
+ parser.setLanguage(language);
321
+ p = parser;
322
+ parsers.set(lang, p);
323
+ return p;
324
+ }
325
+
326
+ // src/analyzer.ts
327
+ var op = (kind, db, hint = "none", orm) => ({ kind, db, hint, orm });
328
+ var JS_OPS = {
329
+ find: op("readMany", "any", "weak"),
330
+ findOne: op("read", "any", "weak"),
331
+ findById: op("read", "mongodb", "weak", "mongoose"),
332
+ exists: op("count", "mongodb", "weak"),
333
+ countDocuments: op("count", "mongodb"),
334
+ estimatedDocumentCount: op("count", "mongodb"),
335
+ count: op("count", "any", "weak"),
336
+ aggregate: op("aggregate", "any", "weak"),
337
+ distinct: op("readMany", "mongodb", "weak"),
338
+ mapReduce: op("aggregate", "mongodb", "weak"),
339
+ updateOne: op("write", "mongodb"),
340
+ updateMany: op("write", "any", "weak"),
341
+ deleteOne: op("write", "mongodb"),
342
+ deleteMany: op("write", "any", "weak"),
343
+ insertOne: op("write", "mongodb"),
344
+ insertMany: op("batchWrite", "mongodb"),
345
+ replaceOne: op("write", "mongodb"),
346
+ bulkWrite: op("batchWrite", "mongodb"),
347
+ findOneAndUpdate: op("write", "mongodb"),
348
+ findOneAndDelete: op("write", "mongodb"),
349
+ findOneAndReplace: op("write", "mongodb"),
350
+ findByIdAndUpdate: op("write", "mongodb", "none", "mongoose"),
351
+ findByIdAndDelete: op("write", "mongodb", "none", "mongoose"),
352
+ findByIdAndRemove: op("write", "mongodb", "none", "mongoose"),
353
+ create: op("write", "any", "weak"),
354
+ save: op("write", "any", "weak"),
355
+ insert: op("write", "any", "strong"),
356
+ update: op("write", "any", "strong"),
357
+ delete: op("write", "any", "strong"),
358
+ remove: op("write", "any", "strong"),
359
+ upsert: op("write", "any", "strong"),
360
+ // Prisma
361
+ findUnique: op("read", "sql", "none", "prisma"),
362
+ findUniqueOrThrow: op("read", "sql", "none", "prisma"),
363
+ findFirst: op("read", "sql", "none", "prisma"),
364
+ findFirstOrThrow: op("read", "sql", "none", "prisma"),
365
+ findMany: op("readMany", "sql", "none", "prisma"),
366
+ createMany: op("batchWrite", "sql", "none", "prisma"),
367
+ $queryRaw: op("raw", "sql"),
368
+ $queryRawUnsafe: op("raw", "sql"),
369
+ $executeRaw: op("raw", "sql"),
370
+ $executeRawUnsafe: op("raw", "sql"),
371
+ // TypeORM
372
+ findOneBy: op("read", "sql", "none", "typeorm"),
373
+ findOneOrFail: op("read", "sql", "none", "typeorm"),
374
+ findOneByOrFail: op("read", "sql", "none", "typeorm"),
375
+ findBy: op("readMany", "sql", "none", "typeorm"),
376
+ findAndCount: op("readMany", "sql", "none", "typeorm"),
377
+ findAndCountBy: op("readMany", "sql", "none", "typeorm"),
378
+ getMany: op("readMany", "sql", "none", "typeorm"),
379
+ getOne: op("read", "sql", "none", "typeorm"),
380
+ getRawMany: op("readMany", "sql", "none", "typeorm"),
381
+ getRawOne: op("read", "sql", "none", "typeorm"),
382
+ // Sequelize
383
+ findAll: op("readMany", "sql", "none", "sequelize"),
384
+ findByPk: op("read", "sql", "none", "sequelize"),
385
+ findOrCreate: op("write", "sql", "none", "sequelize"),
386
+ bulkCreate: op("batchWrite", "sql", "none", "sequelize"),
387
+ // raw drivers (pg, mysql2, knex.raw, sequelize.query, typeorm query)
388
+ query: op("raw", "sql", "strong"),
389
+ execute: op("raw", "sql", "strong"),
390
+ raw: op("raw", "sql", "strong")
391
+ };
392
+ var PY_OPS = {
393
+ find: op("readMany", "mongodb", "weak", "pymongo"),
394
+ find_one: op("read", "mongodb", "none", "pymongo"),
395
+ count_documents: op("count", "mongodb", "none", "pymongo"),
396
+ estimated_document_count: op("count", "mongodb", "none", "pymongo"),
397
+ aggregate: op("aggregate", "mongodb", "weak", "pymongo"),
398
+ distinct: op("readMany", "mongodb", "weak", "pymongo"),
399
+ map_reduce: op("aggregate", "mongodb", "weak", "pymongo"),
400
+ update_one: op("write", "mongodb", "none", "pymongo"),
401
+ update_many: op("write", "mongodb", "none", "pymongo"),
402
+ insert_one: op("write", "mongodb", "none", "pymongo"),
403
+ insert_many: op("batchWrite", "mongodb", "none", "pymongo"),
404
+ delete_one: op("write", "mongodb", "none", "pymongo"),
405
+ delete_many: op("write", "mongodb", "none", "pymongo"),
406
+ replace_one: op("write", "mongodb", "none", "pymongo"),
407
+ bulk_write: op("batchWrite", "mongodb", "none", "pymongo"),
408
+ find_one_and_update: op("write", "mongodb", "none", "pymongo"),
409
+ find_one_and_delete: op("write", "mongodb", "none", "pymongo"),
410
+ find_one_and_replace: op("write", "mongodb", "none", "pymongo"),
411
+ // raw DB-API cursors / SQLAlchemy
412
+ execute: op("raw", "sql", "strong"),
413
+ executemany: op("raw", "sql", "strong"),
414
+ scalars: op("raw", "sql", "strong", "sqlalchemy"),
415
+ scalar: op("raw", "sql", "strong", "sqlalchemy")
416
+ };
417
+ var DJANGO_OPS = {
418
+ get: "read",
419
+ first: "read",
420
+ last: "read",
421
+ earliest: "read",
422
+ latest: "read",
423
+ filter: "readMany",
424
+ all: "readMany",
425
+ exclude: "readMany",
426
+ values: "readMany",
427
+ values_list: "readMany",
428
+ count: "count",
429
+ exists: "count",
430
+ aggregate: "aggregate",
431
+ create: "write",
432
+ update: "write",
433
+ delete: "write",
434
+ get_or_create: "write",
435
+ update_or_create: "write",
436
+ bulk_create: "batchWrite",
437
+ bulk_update: "batchWrite"
438
+ };
439
+ var SQLA_OPS = { query: "readMany", get: "read", add: "write", merge: "write", delete: "write" };
440
+ var GO_OPS = {
441
+ FindOne: op("read", "mongodb", "none", "mongo-go-driver"),
442
+ CountDocuments: op("count", "mongodb", "none", "mongo-go-driver"),
443
+ EstimatedDocumentCount: op("count", "mongodb", "none", "mongo-go-driver"),
444
+ Aggregate: op("aggregate", "mongodb", "none", "mongo-go-driver"),
445
+ Distinct: op("readMany", "mongodb", "none", "mongo-go-driver"),
446
+ UpdateOne: op("write", "mongodb", "none", "mongo-go-driver"),
447
+ UpdateMany: op("write", "mongodb", "none", "mongo-go-driver"),
448
+ UpdateByID: op("write", "mongodb", "none", "mongo-go-driver"),
449
+ InsertOne: op("write", "mongodb", "none", "mongo-go-driver"),
450
+ InsertMany: op("batchWrite", "mongodb", "none", "mongo-go-driver"),
451
+ DeleteOne: op("write", "mongodb", "none", "mongo-go-driver"),
452
+ DeleteMany: op("write", "mongodb", "none", "mongo-go-driver"),
453
+ ReplaceOne: op("write", "mongodb", "none", "mongo-go-driver"),
454
+ BulkWrite: op("batchWrite", "mongodb", "none", "mongo-go-driver"),
455
+ FindOneAndUpdate: op("write", "mongodb", "none", "mongo-go-driver"),
456
+ FindOneAndDelete: op("write", "mongodb", "none", "mongo-go-driver"),
457
+ FindOneAndReplace: op("write", "mongodb", "none", "mongo-go-driver"),
458
+ // database/sql, sqlx, pgx
459
+ Query: op("raw", "sql", "weak"),
460
+ QueryContext: op("raw", "sql", "weak"),
461
+ QueryRow: op("raw", "sql", "weak"),
462
+ QueryRowContext: op("raw", "sql", "weak"),
463
+ Exec: op("raw", "sql", "weak"),
464
+ ExecContext: op("raw", "sql", "weak"),
465
+ Raw: op("raw", "sql", "weak", "gorm"),
466
+ // gorm
467
+ First: op("read", "sql", "weak", "gorm"),
468
+ Take: op("read", "sql", "weak", "gorm"),
469
+ Last: op("read", "sql", "weak", "gorm"),
470
+ Create: op("write", "sql", "weak", "gorm"),
471
+ Save: op("write", "sql", "weak", "gorm"),
472
+ Updates: op("write", "sql", "weak", "gorm"),
473
+ CreateInBatches: op("batchWrite", "sql", "weak", "gorm")
474
+ };
475
+ var HINT_RE = /(model|collection|\bcoll\b|repo|repository|\bdb\b|_db\b|db_|database|prisma|session|cursor|\bcur\b|pool|client|\bconn\b|connection|knex|sequelize|objects|\btx\b|\btrx\b|dao|datasource|\bem\b|entitymanager|mongo|gorm|stmt)/i;
476
+ var SQL_RECEIVER_RE = /(prisma|repository|repo\b|objects|session|entitymanager|\bem\b|knex|sequelize|gorm|_set$)/i;
477
+ var SQL_TEXT_RE = /^\s*(select|insert|update|delete|with|replace)\b/i;
478
+ var MONGO_FILE_RE = /\b(mongoose|mongodb|pymongo|motor|mongoengine|go\.mongodb\.org|bson)\b/;
479
+ var SQL_FILE_RE = /(@prisma\/client|\bprisma\b|typeorm|sequelize|\bknex\b|['"]pg['"]|mysql|sqlite|sqlalchemy|\bdjango\b|gorm\.io|database\/sql|sqlx|psycopg|drizzle|kysely|pgx)/;
480
+ var BUILTIN_ROOTS = /* @__PURE__ */ new Set([
481
+ "Object",
482
+ "Array",
483
+ "Promise",
484
+ "JSON",
485
+ "Math",
486
+ "Date",
487
+ "Reflect",
488
+ "Number",
489
+ "String",
490
+ "Symbol",
491
+ "Map",
492
+ "Set",
493
+ "WeakMap",
494
+ "Intl",
495
+ "Buffer",
496
+ "process",
497
+ "console",
498
+ "window",
499
+ "document",
500
+ "localStorage",
501
+ "sessionStorage",
502
+ "React",
503
+ "Vue",
504
+ "URL",
505
+ "Error",
506
+ "RegExp",
507
+ "Proxy",
508
+ "BigInt",
509
+ "Atomics",
510
+ "Event",
511
+ "Blob",
512
+ "File",
513
+ "FormData",
514
+ "strings",
515
+ "bytes",
516
+ "sort",
517
+ "slices",
518
+ "maps",
519
+ "os",
520
+ "fmt",
521
+ "re",
522
+ "json",
523
+ "math",
524
+ "time",
525
+ "path",
526
+ "filepath"
527
+ ]);
528
+ var DEFAULT_TEST_LOOP_BREAKERS = /* @__PURE__ */ new Set(["while_statement", "do_statement"]);
529
+ var same = (a, b) => !!a && !!b && a.startIndex === b.startIndex && a.endIndex === b.endIndex && a.type === b.type;
530
+ var stripQuotes = (s) => s.replace(/^[rRbBuUfF]{0,2}(['"`]{1,3})([\s\S]*?)\1$/, "$2");
531
+ async function analyzeSource(source, opts) {
532
+ if (/querydig-ignore-file/.test(source.slice(0, 2e3))) return [];
533
+ const parser = await getParser(opts.lang);
534
+ const tree = parser.parse(source);
535
+ try {
536
+ return new Analyzer(source, opts, LANGS[opts.lang]).run(tree.rootNode);
537
+ } finally {
538
+ tree.delete();
539
+ }
540
+ }
541
+ var Analyzer = class {
542
+ constructor(src, opts, spec) {
543
+ this.src = src;
544
+ this.opts = opts;
545
+ this.spec = spec;
546
+ this.lines = src.split("\n");
547
+ this.hasMongo = MONGO_FILE_RE.test(src);
548
+ this.hasSql = SQL_FILE_RE.test(src);
549
+ this.hasMongoose = /\bmongoose\b/.test(src) || /@nestjs\/mongoose|InjectModel/.test(src);
550
+ this.isJs = spec.id === "javascript" || spec.id === "typescript" || spec.id === "tsx";
551
+ }
552
+ src;
553
+ opts;
554
+ spec;
555
+ lines;
556
+ findings = [];
557
+ queryVars = /* @__PURE__ */ new Set();
558
+ hasMongo;
559
+ hasSql;
560
+ hasMongoose;
561
+ isJs;
562
+ run(root) {
563
+ this.visit(root, { depth: 0, over: false });
564
+ return this.applySuppressions();
565
+ }
566
+ // ------------------------------------------------------------- traversal
567
+ visit(node, ctx) {
568
+ const t = node.type;
569
+ if (t === "comment") return;
570
+ if (this.spec.functions.includes(t)) ctx = { depth: 0, over: false };
571
+ if (this.spec.loops.includes(t)) return this.visitLoop(node, ctx);
572
+ if (this.spec.call.includes(t)) return this.visitCall(node, ctx);
573
+ if (this.spec.strings.includes(t)) this.checkSqlString(node);
574
+ for (const c of node.namedChildren) this.visit(c, ctx);
575
+ }
576
+ identsOf(node) {
577
+ if (!node) return [];
578
+ const out = [];
579
+ const stack = [node];
580
+ while (stack.length) {
581
+ const n = stack.pop();
582
+ if (n.type === "identifier" || n.type === "shorthand_property_identifier_pattern") out.push(n.text);
583
+ else if (n.type !== "type_annotation") stack.push(...n.namedChildren);
584
+ }
585
+ return out;
586
+ }
587
+ inner(ctx, node, iterable, forceOver = false, vars) {
588
+ const nextRecv = iterable ? /^([A-Za-z_]\w*)\.Next\(/.exec(iterable.text)?.[1] : void 0;
589
+ const overVar = nextRecv ?? (iterable ? (this.spec.call.includes(iterable.type) || iterable.type === "await_expression") && iterable.text.length <= 60 ? iterable.text.replace(/\s+/g, " ") : this.rootIdent(iterable) : void 0);
590
+ const over = ctx.over || forceOver || (iterable ? this.isOverResults(iterable) : false);
591
+ return {
592
+ depth: ctx.depth + 1,
593
+ over,
594
+ loopLine: node.startPosition.row + 1,
595
+ overVar: over ? overVar ?? ctx.overVar : ctx.overVar,
596
+ loopVars: [...ctx.loopVars ?? [], ...this.identsOf(vars)],
597
+ iterName: iterable ? this.path(iterable) || ctx.iterName : ctx.iterName
598
+ };
599
+ }
600
+ visitLoop(node, ctx) {
601
+ const t = node.type;
602
+ const id = this.spec.id;
603
+ if (DEFAULT_TEST_LOOP_BREAKERS.has(t)) {
604
+ for (const c of node.namedChildren) this.visit(c, ctx);
605
+ return;
606
+ }
607
+ if (id === "go") {
608
+ const range = node.namedChildren.find((c) => c.type === "range_clause");
609
+ const clause = node.namedChildren.find((c) => c.type === "for_clause");
610
+ const body = node.childForFieldName("body") ?? node.namedChildren.find((c) => c.type === "block");
611
+ if (range) {
612
+ const right = range.childForFieldName("right");
613
+ if (right) this.visit(right, ctx);
614
+ const inner2 = this.inner(ctx, node, right, false, range.childForFieldName("left"));
615
+ if (body) this.visit(body, inner2);
616
+ return;
617
+ }
618
+ const cond2 = node.namedChildren.find((c) => c !== body && c.type !== "block" && c.type !== "for_clause");
619
+ if (cond2 && /\.Next\(/.test(cond2.text)) {
620
+ const inner2 = this.inner(ctx, node, cond2, true);
621
+ this.visit(cond2, ctx);
622
+ if (body) this.visit(body, inner2);
623
+ return;
624
+ }
625
+ if (clause) {
626
+ this.visit(clause, ctx);
627
+ if (body) this.visit(body, clause.childForFieldName("condition") ? this.inner(ctx, node, null) : ctx);
628
+ return;
629
+ }
630
+ for (const c of node.namedChildren) this.visit(c, ctx);
631
+ return;
632
+ }
633
+ if (id === "python") {
634
+ if (t === "for_statement") {
635
+ const right = node.childForFieldName("right");
636
+ if (right) this.visit(right, ctx);
637
+ const inner3 = this.inner(ctx, node, right, false, node.childForFieldName("left"));
638
+ for (const c of node.namedChildren) if (!same(c, right) && !same(c, node.childForFieldName("left"))) this.visit(c, inner3);
639
+ return;
640
+ }
641
+ const clauses = node.namedChildren.filter((c) => c.type === "for_in_clause");
642
+ const firstRight = clauses[0]?.childForFieldName("right") ?? null;
643
+ if (firstRight) this.visit(firstRight, ctx);
644
+ const inner2 = this.inner(ctx, node, firstRight, false, clauses[0]?.childForFieldName("left"));
645
+ for (const c of node.namedChildren) {
646
+ if (c.type === "for_in_clause") {
647
+ const r2 = c.childForFieldName("right");
648
+ if (r2 && !same(r2, firstRight)) this.visit(r2, inner2);
649
+ } else this.visit(c, inner2);
650
+ }
651
+ return;
652
+ }
653
+ if (t === "for_in_statement") {
654
+ const right = node.childForFieldName("right");
655
+ if (right) this.visit(right, ctx);
656
+ const inner2 = this.inner(ctx, node, right, false, node.childForFieldName("left"));
657
+ const body = node.childForFieldName("body");
658
+ if (body) this.visit(body, inner2);
659
+ return;
660
+ }
661
+ const cond = node.childForFieldName("condition");
662
+ const isInfinite = !cond || cond.text.trim() === ";" || cond.text.trim() === "true;" || cond.text.trim() === "";
663
+ const inner = isInfinite ? ctx : this.inner(ctx, node, null);
664
+ for (const c of node.namedChildren) this.visit(c, inner);
665
+ }
666
+ visitCall(node, ctx) {
667
+ const q = this.classify(node);
668
+ if (q) this.evaluate(q, ctx);
669
+ const callee = node.childForFieldName("function");
670
+ const memberFields = callee ? this.spec.member[callee.type] : void 0;
671
+ if (callee && memberFields && this.spec.iterMethods.length) {
672
+ const prop = callee.childForFieldName(memberFields[1])?.text ?? "";
673
+ const args = this.argNodes(node);
674
+ if (this.spec.iterMethods.includes(prop) && args.some((a) => this.spec.lambdas.includes(a.type))) {
675
+ const recv = callee.childForFieldName(memberFields[0]);
676
+ if (recv) this.visit(recv, ctx);
677
+ for (const a of args) {
678
+ if (!this.spec.lambdas.includes(a.type)) {
679
+ this.visit(a, ctx);
680
+ continue;
681
+ }
682
+ const params = a.childForFieldName("parameters") ?? a.childForFieldName("parameter");
683
+ this.visit(a, this.inner(ctx, node, recv, false, params));
684
+ }
685
+ return;
686
+ }
687
+ }
688
+ for (const c of node.namedChildren) this.visit(c, ctx);
689
+ }
690
+ // ------------------------------------------------------------- helpers
691
+ argNodes(call) {
692
+ const a = call.childForFieldName("arguments");
693
+ if (!a) return [];
694
+ if (a.type === "template_string" || a.type === "generator_expression") return [a];
695
+ return a.namedChildren.filter((c) => c.type !== "comment");
696
+ }
697
+ /** Dotted path of an expression without call arguments: `db.collection().find` */
698
+ path(node, depth = 0) {
699
+ if (!node || depth > 12) return "";
700
+ const mf = this.spec.member[node.type];
701
+ if (mf) return `${this.path(node.childForFieldName(mf[0]), depth + 1)}.${node.childForFieldName(mf[1])?.text ?? ""}`;
702
+ if (this.spec.call.includes(node.type)) return `${this.path(node.childForFieldName("function"), depth + 1)}()`;
703
+ switch (node.type) {
704
+ case "identifier":
705
+ case "this":
706
+ case "self":
707
+ case "property_identifier":
708
+ case "field_identifier":
709
+ case "type_identifier":
710
+ return node.text;
711
+ case "await_expression":
712
+ case "parenthesized_expression":
713
+ case "non_null_expression":
714
+ case "as_expression":
715
+ case "await":
716
+ return this.path(node.namedChildren[0] ?? null, depth + 1);
717
+ case "subscript":
718
+ case "subscript_expression":
719
+ case "index_expression":
720
+ return `${this.path(node.namedChildren[0] ?? null, depth + 1)}[]`;
721
+ default:
722
+ return node.text.length < 40 ? node.text : node.type;
723
+ }
724
+ }
725
+ rootIdent(node) {
726
+ const p = this.path(node);
727
+ const m = /^[A-Za-z_$][\w$]*/.exec(p.replace(/^(this|self)\./, ""));
728
+ return m?.[0];
729
+ }
730
+ containsQuery(node, depth = 0) {
731
+ if (depth > 40) return false;
732
+ if (this.spec.call.includes(node.type) && this.classify(node, true)) return true;
733
+ return node.namedChildren.some((c) => this.containsQuery(c, depth + 1));
734
+ }
735
+ isOverResults(iterable) {
736
+ const id = this.rootIdent(iterable);
737
+ if (id && this.queryVars.has(id)) return true;
738
+ return this.containsQuery(iterable);
739
+ }
740
+ collectionName(receiver) {
741
+ if (!receiver) return void 0;
742
+ const txt = receiver.text;
743
+ const m = /(?:collection|Collection|getCollection)\(\s*['"`]([\w.-]+)['"`]/.exec(txt) ?? /\[\s*['"]([\w.-]+)['"]\s*\]$/.exec(txt);
744
+ if (m) return m[1];
745
+ const p = this.path(receiver).split(".").filter(Boolean);
746
+ const last = p[p.length - 1];
747
+ if (!last || last.endsWith("()")) return void 0;
748
+ if (/^(objects|db|this|self|prisma)$/i.test(last)) return p.length > 1 && last === "objects" ? p[p.length - 2] : void 0;
749
+ return last.replace(/(Model|Repository|Repo|Collection|Coll)$/i, "") || last;
750
+ }
751
+ // ------------------------------------------------------------- classification
752
+ classify(node, quick = false) {
753
+ const callee = node.childForFieldName("function");
754
+ if (!callee) return null;
755
+ const mf = this.spec.member[callee.type];
756
+ if (!mf) return null;
757
+ const method = callee.childForFieldName(mf[1])?.text ?? "";
758
+ const receiverNode = callee.childForFieldName(mf[0]);
759
+ if (!method || !receiverNode) return null;
760
+ const receiver = this.path(receiverNode);
761
+ if (BUILTIN_ROOTS.has(receiver.split(/[.([]/)[0])) return null;
762
+ const args = this.argNodes(node);
763
+ const first = args[0];
764
+ let def;
765
+ const id = this.spec.id;
766
+ if (id === "python") {
767
+ if (/(^|\.)objects(\.|\(|$)|_set$/.test(receiver) && DJANGO_OPS[method]) def = op(DJANGO_OPS[method], "sql", "none", "django");
768
+ else if (/session|\bdb\b/i.test(receiver) && SQLA_OPS[method] && !/\.objects/.test(receiver)) def = op(SQLA_OPS[method], "sql", "none", "sqlalchemy");
769
+ else def = PY_OPS[method];
770
+ if (def && method === "find" && first && first.type === "string") return null;
771
+ } else if (id === "go") {
772
+ if (method === "Find") {
773
+ def = this.hasMongo && first && /\b(ctx|context|Context\(\)|TODO\(\)|Background\(\))/.test(first.text) ? op("readMany", "mongodb", "none", "mongo-go-driver") : op("readMany", "sql", "weak", "gorm");
774
+ } else def = GO_OPS[method];
775
+ } else {
776
+ def = Object.prototype.hasOwnProperty.call(JS_OPS, method) ? JS_OPS[method] : void 0;
777
+ if (def && first && this.spec.lambdas.includes(first.type)) return null;
778
+ if (def && (method === "find" || method === "findOne") && first && (first.type === "string" || first.type === "number")) return null;
779
+ }
780
+ if (!def) return null;
781
+ const firstText = first ? stripQuotes(first.text) : "";
782
+ const sqlArg = SQL_TEXT_RE.test(firstText);
783
+ const hinted = HINT_RE.test(receiver);
784
+ const capitalized = /^[A-Z]/.test(receiver.replace(/^(this|self)\./, ""));
785
+ const isChainedFromCall = receiver.endsWith("()");
786
+ if (def.kind === "raw") {
787
+ const plainString = !!first && this.spec.strings.includes(first.type) && !sqlArg;
788
+ if (!sqlArg && !(hinted && args.length > 0 && !plainString)) return null;
789
+ } else {
790
+ if (def.hint === "strong" && !hinted && !sqlArg) return null;
791
+ const docSave = this.isJs && method === "save" && args.length === 0 && (this.hasMongo || this.hasSql);
792
+ if (def.hint === "weak" && !hinted && !capitalized && !sqlArg && !docSave && !(id === "go" && isChainedFromCall)) return null;
793
+ }
794
+ if (method === "save" && this.isJs && args.length > 1) return null;
795
+ let down = receiverNode;
796
+ const downChain = [];
797
+ while (down && this.spec.call.includes(down.type)) {
798
+ const f = down.childForFieldName("function");
799
+ const fm = f ? this.spec.member[f.type] : void 0;
800
+ if (!f || !fm) break;
801
+ if (!quick && this.classify(down, true)) return null;
802
+ downChain.push({ name: f.childForFieldName(fm[1])?.text ?? "", args: down.childForFieldName("arguments")?.text ?? "", node: down });
803
+ down = f.childForFieldName(fm[0]);
804
+ }
805
+ if (quick) return { node, top: node, method, kind: def.kind, db: "unknown", receiver, receiverText: "", args, argsText: "", chain: [], confidence: "low" };
806
+ const up = [];
807
+ let top = node;
808
+ for (; ; ) {
809
+ const p = top.parent;
810
+ const pm = p ? this.spec.member[p.type] : void 0;
811
+ if (!p || !pm || !same(p.childForFieldName(pm[0]), top)) break;
812
+ const pp = p.parent;
813
+ if (!pp || !this.spec.call.includes(pp.type) || !same(pp.childForFieldName("function"), p)) {
814
+ break;
815
+ }
816
+ up.push({ name: p.childForFieldName(pm[1])?.text ?? "", args: pp.childForFieldName("arguments")?.text ?? "", node: pp });
817
+ top = pp;
818
+ }
819
+ let db = def.db === "any" ? "unknown" : def.db;
820
+ if (def.db === "any") {
821
+ if (SQL_RECEIVER_RE.test(receiver) || sqlArg) db = "sql";
822
+ else if (/collection|mongo/i.test(receiver)) db = "mongodb";
823
+ else if (this.hasMongo && !this.hasSql) db = "mongodb";
824
+ else if (this.hasSql && !this.hasMongo) db = "sql";
825
+ else if (this.hasMongoose) db = "mongodb";
826
+ }
827
+ if (def.kind === "raw") db = "sql";
828
+ const confidence = def.hint === "none" || sqlArg ? "high" : hinted ? "medium" : "low";
829
+ const argsNode = node.childForFieldName("arguments");
830
+ return {
831
+ node,
832
+ top,
833
+ method,
834
+ kind: def.kind,
835
+ db,
836
+ orm: def.orm ?? (this.hasMongoose && db === "mongodb" && this.isJs ? "mongoose" : void 0),
837
+ receiver,
838
+ receiverText: receiverNode.text,
839
+ collection: this.collectionName(receiverNode),
840
+ args,
841
+ argsText: argsNode?.text ?? "",
842
+ chain: [...downChain.reverse(), ...up],
843
+ confidence
844
+ };
845
+ }
846
+ // ------------------------------------------------------------- rules
847
+ evaluate(q, ctx) {
848
+ this.trackAssignment(q);
849
+ const target = q.collection ? `${q.collection}.${q.method}` : `${q.receiver}.${q.method}`;
850
+ const loopInfo = ctx.loopLine ? ` (loop at line ${ctx.loopLine})` : "";
851
+ const paginating = q.chain.some((c) => /^(skip|Skip|offset|Offset)$/.test(c.name) || /^(Limit)$/.test(c.name) && c.args.includes(","));
852
+ if (ctx.depth > 0 && !paginating && !this.targetVariesPerIteration(q, ctx)) {
853
+ const rawWrite = q.kind === "raw" && /^\s*(insert|update|delete|replace)\b/i.test(q.args[0] ? stripQuotes(q.args[0].text) : "");
854
+ const nested = ctx.depth >= 2 ? ` It is nested ${ctx.depth} loops deep, so the number of queries grows multiplicatively.` : "";
855
+ if (q.kind === "write" || q.kind === "batchWrite" || rawWrite) {
856
+ this.add("QD003", q.node, q, {
857
+ severity: q.kind === "batchWrite" ? "medium" : ctx.depth >= 2 ? "critical" : "high",
858
+ message: `\`${target}()\` runs once per iteration${loopInfo}.${nested}`,
859
+ fix: q.db === "mongodb" ? "Collect the operations in an array and send them with one bulkWrite()/insertMany() call." : void 0
860
+ });
861
+ } else if (ctx.over) {
862
+ this.add("QD002", q.node, q, {
863
+ severity: "critical",
864
+ message: `\`${target}()\` is executed for every row of \`${ctx.overVar ?? "a previous query"}\`, which itself came from the database${loopInfo}.${nested}`,
865
+ fix: this.batchFix(q)
866
+ });
867
+ } else {
868
+ this.add("QD001", q.node, q, {
869
+ severity: ctx.depth >= 2 ? "critical" : "high",
870
+ message: `\`${target}()\` is called inside a loop${loopInfo} \u2014 one round-trip per item.${nested}`,
871
+ fix: this.batchFix(q)
872
+ });
873
+ }
874
+ }
875
+ if (q.db === "mongodb") this.mongoRules(q);
876
+ if (q.db === "sql") this.sqlOrmRules(q);
877
+ }
878
+ /** `collections.map(c => c.deleteMany({}))` hits a different collection each time: not N+1. */
879
+ targetVariesPerIteration(q, ctx) {
880
+ const vars = ctx.loopVars ?? [];
881
+ if (!vars.length) return false;
882
+ const COLLECTIONISH = /(collection|coll\b|model|table|schema|repo|index|db\b|database|tenant|shard)/i;
883
+ for (const v of vars) {
884
+ const re = new RegExp(`(^|[^\\w$.])${v.replace(/\$/g, "\\$")}\\b`);
885
+ if (!re.test(q.receiverText)) continue;
886
+ if (COLLECTIONISH.test(v) || COLLECTIONISH.test(ctx.iterName ?? "") || /subclasses|models/i.test(ctx.iterName ?? "")) return true;
887
+ if (/^[A-Z]/.test(v) && q.receiver.split(".")[0] === v) return true;
888
+ if (new RegExp(`(collection|Collection|model|Model|table|Table)\\([^)]*\\b${v.replace(/\$/g, "\\$")}\\b`).test(q.receiverText)) return true;
889
+ }
890
+ return false;
891
+ }
892
+ batchFix(q) {
893
+ if (q.db === "mongodb") {
894
+ const keys = this.filterKeys(q);
895
+ const k = keys[0] ?? "_id";
896
+ return `Collect the ${k} values first and run a single \`${q.collection ?? "Model"}.find({ ${k}: { $in: values } })\`, then match results in memory with a Map. Or use $lookup in one aggregation.`;
897
+ }
898
+ if (q.orm === "django") return "Use select_related() / prefetch_related() on the outer queryset, or filter(id__in=ids) once.";
899
+ if (q.orm === "gorm") return 'Use Preload()/Joins() on the outer query or a single Where("id IN ?", ids).Find().';
900
+ if (q.orm === "prisma") return "Use include/select relations on the outer query, or findMany({ where: { id: { in: ids } } }).";
901
+ if (q.db === "sql") return "Use a JOIN or a single `WHERE id IN (...)`/`= ANY($1)` query and group the rows in memory.";
902
+ return void 0;
903
+ }
904
+ trackAssignment(q) {
905
+ let n = q.top.parent;
906
+ for (let i = 0; n && i < 4; i++) {
907
+ if (["await_expression", "parenthesized_expression", "as_expression", "non_null_expression", "expression_list", "await"].includes(n.type)) {
908
+ n = n.parent;
909
+ continue;
910
+ }
911
+ break;
912
+ }
913
+ if (!n) return;
914
+ let target = null;
915
+ if (n.type === "variable_declarator") target = n.childForFieldName("name");
916
+ else if (n.type === "assignment_expression" || n.type === "assignment" || n.type === "augmented_assignment") target = n.childForFieldName("left");
917
+ else if (n.type === "short_var_declaration" || n.type === "assignment_statement") target = n.childForFieldName("left");
918
+ if (!target) return;
919
+ const name = /^[A-Za-z_$][\w$]*/.exec(target.text.replace(/^[\s[{(]+/, "").replace(/^(this|self)\./, ""));
920
+ if (name) this.queryVars.add(name[0]);
921
+ }
922
+ filterNode(q) {
923
+ if (this.spec.id === "go") return q.args[1];
924
+ return q.args[0];
925
+ }
926
+ filterKeys(q) {
927
+ const f = this.filterNode(q);
928
+ return f ? this.objectKeys(f) : [];
929
+ }
930
+ objectKeys(node) {
931
+ const keys = [];
932
+ const push = (k) => {
933
+ if (!k) return;
934
+ const s = stripQuotes(k.trim());
935
+ if (s && !s.startsWith("$") && /^[\w.]+$/.test(s)) keys.push(s);
936
+ };
937
+ if (node.type === "object" || node.type === "dictionary") {
938
+ for (const c of node.namedChildren) if (c.type === "pair") push(c.childForFieldName("key")?.text);
939
+ else if (c.type === "shorthand_property_identifier") push(c.text);
940
+ } else if (node.type === "composite_literal") {
941
+ const body = node.childForFieldName("body");
942
+ for (const c of body?.namedChildren ?? []) {
943
+ if (c.type === "keyed_element") push(c.namedChildren[0]?.text);
944
+ else if (c.type === "literal_value") push(c.namedChildren[0]?.text);
945
+ }
946
+ }
947
+ return keys;
948
+ }
949
+ isEmptyFilter(node) {
950
+ if (!node) return true;
951
+ const t = node.text.replace(/\s+/g, "");
952
+ return /^(\{\}|bson\.(M|D)\{\}|primitive\.(M|D)\{\}|dict\(\)|map\[string\](interface\{\}|any)\{\})$/.test(t);
953
+ }
954
+ chainHas(q, re) {
955
+ return q.chain.some((c) => re.test(c.name));
956
+ }
957
+ mongoRules(q) {
958
+ const filter = this.filterNode(q);
959
+ const allText = q.argsText + " " + q.chain.map((c) => `${c.name}${c.args}`).join(" ");
960
+ const hasLimit = this.chainHas(q, /^(limit|Limit|first|next|batch_size)$/) || /\b(limit|SetLimit|\$limit)\b/.test(q.argsText);
961
+ const isFind = q.method === "find" || q.method === "Find";
962
+ if (isFind) {
963
+ const streaming = this.chainHas(q, /^(cursor|stream|eachAsync|batchSize|batch_size)$/);
964
+ const unknownOpts = this.spec.id === "go" && q.args.slice(2).some((a) => a.type === "identifier");
965
+ if (this.isEmptyFilter(filter) && !hasLimit && !unknownOpts) {
966
+ this.add("MQ001", q.node, q, {
967
+ severity: streaming ? "medium" : "high",
968
+ message: `\`${q.collection ?? q.receiver}.${q.method}()\` has an empty filter and no limit: it reads the whole collection${streaming ? " (streamed via cursor, but still a full COLLSCAN)" : ""}.`
969
+ });
970
+ }
971
+ const hasProjection = this.chainHas(q, /^(select|project|projection|only)$/) || /projection|SetProjection/.test(allText) || this.isJs && q.orm === "mongoose" && q.args.length >= 2 || this.spec.id === "python" && q.args.length >= 2 || unknownOpts;
972
+ if (!hasProjection && !this.isEmptyFilter(filter)) {
973
+ this.add("MQ010", q.node, q, { message: `\`${q.collection ?? q.receiver}.${q.method}()\` returns full documents.` });
974
+ }
975
+ }
976
+ const modelLike = /^[A-Z]/.test(q.receiver.replace(/^this\./, "")) || /model$/i.test(q.receiver);
977
+ if (this.isJs && q.orm === "mongoose" && modelLike && ["find", "findOne", "findById"].includes(q.method) && !this.chainHas(q, /^lean$/)) {
978
+ const usesSave = this.chainHas(q, /^(save|populate)$/);
979
+ if (!usesSave && q.method === "find") this.add("MQ011", q.node, q, { message: `\`${q.collection ?? q.receiver}.find()\` hydrates full Mongoose documents.`, confidence: "low" });
980
+ }
981
+ const hasSort = this.chainHas(q, /^(sort|Sort)$/) || /\bSetSort\(|\bsort\s*=/.test(q.argsText);
982
+ if ((isFind || q.kind === "readMany") && hasSort && !hasLimit) {
983
+ const keys = this.filterKeys(q);
984
+ const sortLink = q.chain.find((c) => /^sort$/i.test(c.name));
985
+ const sortKeys = sortLink ? (/\{([^}]*)\}/.exec(sortLink.args)?.[1] ?? "").split(",").map((s) => {
986
+ const [k, v] = s.split(":").map((x) => x.trim().replace(/['"]/g, ""));
987
+ return k ? `${k}: ${v === "-1" || v === "desc" || v === "descending" ? -1 : 1}` : "";
988
+ }).filter(Boolean) : [];
989
+ const idx = [...keys.map((k) => `${k}: 1`), ...sortKeys.filter((k) => !keys.includes(k.split(":")[0]))];
990
+ this.add("MQ005", q.node, q, {
991
+ message: `\`${q.collection ?? q.receiver}.${q.method}()\` sorts without a limit.`,
992
+ fix: idx.length ? `Add .limit(n) and an index matching filter + sort (ESR order), e.g. createIndex({ ${idx.join(", ")} }).` : void 0
993
+ });
994
+ }
995
+ const skipLink = q.chain.find((c) => /^(skip|Skip)$/.test(c.name));
996
+ const skipInArgs = /\bSetSkip\(|\bskip\s*=|["']?\$skip["']?\s*:/.test(q.argsText);
997
+ if (skipLink || skipInArgs) {
998
+ const lit = skipLink ? /^\(\s*(\d+)\s*\)$/.exec(skipLink.args) : null;
999
+ if (!lit || Number(lit[1]) >= 1e3) {
1000
+ this.add("MQ004", skipLink?.node ?? q.node, q, { message: "Offset pagination with skip() gets slower for every page." });
1001
+ }
1002
+ }
1003
+ const regexIssue = this.regexIssue(q);
1004
+ if (regexIssue) this.add("MQ002", q.node, q, { message: regexIssue, severity: regexIssue.startsWith("Case-insensitive") ? "medium" : void 0 });
1005
+ if (/\$where|\$function|\$accumulator/.test(q.argsText) || /^(mapReduce|map_reduce|MapReduce)$/.test(q.method)) {
1006
+ this.add("MQ003", q.node, q, { message: "Executes JavaScript on the server for each document; indexes cannot be used." });
1007
+ }
1008
+ if (q.kind !== "aggregate" && filter && /["']?\$(nin|ne|not)["']?\s*[:,]/.test(filter.text)) {
1009
+ this.add("MQ009", q.node, q, { message: "Filter relies on $ne/$nin/$not, which are not selective." });
1010
+ }
1011
+ if (q.kind === "count" && q.method !== "exists" && this.usedAsBoolean(q.top)) {
1012
+ this.add("MQ008", q.node, q, { message: `\`${q.method}()\` result is only compared against 0.` });
1013
+ }
1014
+ if (q.kind === "aggregate" && filter) {
1015
+ const stages = this.pipelineStages(filter);
1016
+ if (stages) {
1017
+ const firstMatch = stages.findIndex((s) => s === "match" || s === "search" || s === "geoNear");
1018
+ const heavy = stages.findIndex((s) => ["lookup", "unwind", "group", "graphLookup", "facet"].includes(s));
1019
+ if (heavy >= 0 && (firstMatch < 0 || firstMatch > heavy)) {
1020
+ this.add("MQ006", q.node, q, { message: `Pipeline runs $${stages[heavy]} before ${firstMatch < 0 ? "any $match" : "$match"} (stages: ${stages.map((s) => "$" + s).join(" \u2192 ")}).` });
1021
+ }
1022
+ const sortIdx = stages.indexOf("sort");
1023
+ if (sortIdx >= 0 && !stages.slice(sortIdx).includes("limit") && !stages.slice(0, sortIdx).includes("group")) {
1024
+ this.add("MQ005", q.node, q, { message: "Pipeline $sort without a following $limit." });
1025
+ }
1026
+ }
1027
+ }
1028
+ const pops = q.chain.filter((c) => c.name === "populate");
1029
+ if (pops.length >= 3 || pops.some((p) => /populate\s*:/.test(p.args))) {
1030
+ this.add("MQ007", pops[0].node, q, { message: `${pops.length} populate() call(s)${pops.some((p) => /populate\s*:/.test(p.args)) ? " with nested populate" : ""}: each path is an extra query.` });
1031
+ }
1032
+ }
1033
+ regexIssue(q) {
1034
+ const text = q.argsText;
1035
+ for (const a of q.args) {
1036
+ const stack = [a];
1037
+ while (stack.length) {
1038
+ const n = stack.pop();
1039
+ if (n.type === "regex") {
1040
+ const pat = n.childForFieldName("pattern")?.text ?? "";
1041
+ const flags = n.childForFieldName("flags")?.text ?? "";
1042
+ if (!pat.startsWith("^")) return `Regex /${pat}/${flags} is not anchored with ^ \u2014 it scans every key.`;
1043
+ if (flags.includes("i")) return `Case-insensitive regex /${pat}/i cannot use the index efficiently (anchored but case-insensitive).`;
1044
+ }
1045
+ stack.push(...n.namedChildren);
1046
+ }
1047
+ }
1048
+ const caseI = /\$options["']?\s*[:,]\s*["'`][^"'`]*i/.test(text) || /re\.(I|IGNORECASE)\b/.test(text) || /Options:\s*"[^"]*i/.test(text);
1049
+ const m = /["']?\$regex["']?\s*[:,]\s*([^,}\]\n]+)/.exec(text);
1050
+ if (m) {
1051
+ const v = m[1].trim();
1052
+ if (/^["'`]/.test(v)) {
1053
+ if (!/^["'`]\^/.test(v)) return `$regex ${v} is not anchored with ^ \u2014 it scans every key.`;
1054
+ if (caseI) return "Case-insensitive $regex cannot use the index efficiently (anchored but case-insensitive).";
1055
+ } else return `$regex built from a variable (\`${v.slice(0, 40)}\`) \u2014 usually unanchored${caseI ? " and case-insensitive" : ""}; this scans every key.`;
1056
+ }
1057
+ const ctor = /(new RegExp|re\.compile|primitive\.Regex\{\s*Pattern:)\s*\(?\s*([^,)}\n]+)/.exec(text);
1058
+ if (ctor) {
1059
+ const v = ctor[2].trim();
1060
+ if (!/^["'`]\^/.test(v)) return `Regex built with ${ctor[1]}(${v.slice(0, 40)}) is not anchored with ^ \u2014 it scans every key.`;
1061
+ if (caseI || /,\s*["'`][^"'`]*i/.test(text)) return "Case-insensitive regex cannot use the index efficiently (anchored but case-insensitive).";
1062
+ }
1063
+ return null;
1064
+ }
1065
+ pipelineStages(node) {
1066
+ let elems = [];
1067
+ if (node.type === "array" || node.type === "list") elems = node.namedChildren;
1068
+ else if (node.type === "composite_literal") elems = node.childForFieldName("body")?.namedChildren ?? [];
1069
+ else return null;
1070
+ elems = elems.filter((e) => e.type !== "comment");
1071
+ const stages = [];
1072
+ for (const e of elems) {
1073
+ const m = /["']?\$(\w+)["']?\s*[:,]/.exec(e.text);
1074
+ if (!m) return null;
1075
+ stages.push(m[1]);
1076
+ }
1077
+ return stages.length ? stages : null;
1078
+ }
1079
+ usedAsBoolean(top) {
1080
+ let n = top.parent;
1081
+ while (n && ["await_expression", "parenthesized_expression", "await"].includes(n.type)) n = n.parent;
1082
+ if (!n) return false;
1083
+ if (n.type === "unary_expression" && n.text.trim().startsWith("!")) return true;
1084
+ if (n.type === "not_operator") return true;
1085
+ if (n.type === "binary_expression" || n.type === "comparison_operator") {
1086
+ return /(>|>=|===?|!==?|==|!=)\s*[01]\b|\b0\s*(<|===?|!==?)/.test(n.text);
1087
+ }
1088
+ if (n.type === "if_statement" || n.type === "ternary_expression" || n.type === "conditional_expression") return true;
1089
+ return false;
1090
+ }
1091
+ sqlOrmRules(q) {
1092
+ if (q.kind !== "readMany") return;
1093
+ const hasWhere = this.chainHas(q, /^(where|Where|filter|filter_by|exclude|get|Joins)$/) || q.args.some((a) => /\b(where|take|limit|first|cursor)\b/.test(a.text));
1094
+ const hasLimit = this.chainHas(q, /^(limit|Limit|take|first|one|one_or_none|scalar|paginate|get)$/) || q.top.parent?.type === "subscript" || q.top.parent?.type === "slice";
1095
+ let unbounded = false;
1096
+ if (q.orm === "django") unbounded = q.method === "all" && !hasLimit;
1097
+ else if (q.orm === "sqlalchemy") unbounded = q.method === "query" && !hasWhere && !hasLimit && this.chainHas(q, /^all$/);
1098
+ else if (q.orm === "gorm") unbounded = q.chain.length === 0 && /^[a-z]\w*$/.test(q.receiver) && q.args.length <= 1;
1099
+ else if (this.isJs && ["findMany", "findAll", "find", "findBy", "findAndCount", "getMany"].includes(q.method)) {
1100
+ const arg = q.args[0];
1101
+ const argHasFilter = arg && !this.isEmptyFilter(arg) && /\b(where|take|limit|skip|first|last|cursor|id)\b|^\{\s*\w+\s*:/.test(arg.text);
1102
+ unbounded = !argHasFilter && !hasWhere && !hasLimit && q.method !== "getMany";
1103
+ }
1104
+ if (!unbounded) return;
1105
+ const assignedTo = q.top.parent?.type === "assignment" ? q.top.parent.childForFieldName("left")?.text : void 0;
1106
+ if (q.orm === "django" && assignedTo && /^(queryset|self\.queryset|model)$/.test(assignedTo)) return;
1107
+ this.add("SQ001", q.node, q, {
1108
+ message: `\`${q.receiver}.${q.method}()\` has no filter and no limit \u2014 loads the whole table.`,
1109
+ severity: q.orm === "django" ? "medium" : void 0
1110
+ });
1111
+ }
1112
+ // ------------------------------------------------------------- raw SQL strings
1113
+ checkSqlString(node) {
1114
+ let txt = node.text;
1115
+ const dynamic = node.type === "template_string" && /\$\{/.test(txt) || node.namedChildren.some((c) => c.type === "interpolation");
1116
+ if (node.type === "template_string") txt = txt.replace(/\$\{[^}]*\}/g, "?");
1117
+ txt = stripQuotes(txt).replace(/\s+/g, " ").trim();
1118
+ if (!SQL_TEXT_RE.test(txt)) return;
1119
+ if (!/^\s*(SELECT|INSERT|UPDATE|DELETE|WITH|REPLACE)\b/.test(txt) && !/(\bwhere\b|\bjoin\b|\blimit\b|order by|group by|\bvalues\b|\$\d|\?|=|\*|`|"\w+"\.)/i.test(txt)) return;
1120
+ if (/^select\b/i.test(txt) && !/\bfrom\b/i.test(txt)) return;
1121
+ if (/^(update)\b/i.test(txt) && !/\bset\b/i.test(txt)) return;
1122
+ if (/^(delete)\b/i.test(txt) && !/\bfrom\b/i.test(txt)) return;
1123
+ if (/^insert\b/i.test(txt) && !/\binto\b/i.test(txt)) return;
1124
+ const pseudo = { node, top: node, method: "sql", kind: "raw", db: "sql", receiver: "sql", receiverText: "", args: [], argsText: txt, chain: [], confidence: "high" };
1125
+ const concatenated = !!node.parent && ["binary_expression", "concatenated_string", "binary_operator"].includes(node.parent.type);
1126
+ if (/^select\s+(\w+\.)?\*\s+from/i.test(txt)) this.add("SQ002", node, pseudo, { message: `\`${txt.slice(0, 60)}\` selects every column.` });
1127
+ if (/\b(i?like)\s+['"]%/i.test(txt) || /\b(i?like)\s+(concat\(\s*)?['"]%['"]\s*(\|\||,|\+)/i.test(txt)) {
1128
+ this.add("SQ003", node, pseudo, { message: "LIKE pattern starts with % \u2014 no index can be used." });
1129
+ }
1130
+ if (!concatenated && !dynamic) {
1131
+ if (/^(update|delete)\b/i.test(txt) && !/\bwhere\b/i.test(txt)) {
1132
+ this.add("SQ004", node, pseudo, { message: `\`${txt.slice(0, 50)}\` has no WHERE clause \u2014 touches every row.`, severity: "high" });
1133
+ } else if (/^select\b/i.test(txt) && !/\b(where|limit|top|fetch\s+first|group\s+by|join)\b/i.test(txt) && !/^select\s+(count|sum|avg|min|max|exists)\s*\(/i.test(txt) && !/^select\s+\?/.test(txt)) {
1134
+ this.add("SQ004", node, pseudo, { message: `\`${txt.slice(0, 50)}\` has no WHERE and no LIMIT \u2014 full table scan.`, severity: "medium" });
1135
+ }
1136
+ }
1137
+ if (/\boffset\s+(\$\d+|\?|:\w+|@\w+|%s|\d{4,})/i.test(txt) || /\blimit\s+\S+\s*,\s*\S+/i.test(txt)) {
1138
+ this.add("SQ005", node, pseudo, { message: "OFFSET-based pagination reads and discards all skipped rows." });
1139
+ }
1140
+ if (/\b(where|and|or)\s+(lower|upper|date|year|month|day|coalesce|cast|substring|substr|trim|to_char|date_trunc|ifnull|isnull)\s*\(/i.test(txt)) {
1141
+ this.add("SQ006", node, pseudo, { message: "Function wrapped around a column in WHERE makes it non-sargable." });
1142
+ }
1143
+ if (/order\s+by\s+(rand|random|newid)\s*\(\s*\)/i.test(txt)) this.add("SQ007", node, pseudo, { message: "ORDER BY random sorts the entire table." });
1144
+ if (/\bnot\s+in\s*\(\s*select\b/i.test(txt)) this.add("SQ008", node, pseudo, { message: "NOT IN (SELECT ...) \u2014 prefer NOT EXISTS." });
1145
+ }
1146
+ // ------------------------------------------------------------- output
1147
+ add(ruleId, node, q, o) {
1148
+ const rule = RULE_MAP.get(ruleId);
1149
+ const line = node.startPosition.row + 1;
1150
+ const endRow = Math.min(node.endPosition.row, node.startPosition.row + 2);
1151
+ const snippet = this.lines.slice(node.startPosition.row, endRow + 1).map((l) => l.replace(/\s+$/, "")).join("\n").trim();
1152
+ const norm = snippet.replace(/\s+/g, " ");
1153
+ const fingerprint = createHash("sha1").update(`${ruleId}|${this.opts.relPath}|${norm}`).digest("hex").slice(0, 16);
1154
+ if (this.findings.some((f) => f.ruleId === ruleId && f.line === line && f.column === node.startPosition.column + 1)) return;
1155
+ this.findings.push({
1156
+ ruleId,
1157
+ title: rule.title,
1158
+ severity: o.severity ?? rule.severity,
1159
+ db: q.db,
1160
+ lang: this.opts.lang,
1161
+ file: this.opts.relPath,
1162
+ line,
1163
+ column: node.startPosition.column + 1,
1164
+ snippet,
1165
+ message: o.message,
1166
+ fix: o.fix ?? rule.fix,
1167
+ confidence: o.confidence ?? q.confidence,
1168
+ fingerprint
1169
+ });
1170
+ }
1171
+ applySuppressions() {
1172
+ const re = /querydig-ignore(?!-file)(?:[\s:]+((?:[A-Z]{2}\d{3}[\s,]*)+))?/;
1173
+ return this.findings.filter((f) => {
1174
+ for (const ln of [f.line, f.line - 1]) {
1175
+ const m = re.exec(this.lines[ln - 1] ?? "");
1176
+ if (m && (!m[1] || m[1].split(/[\s,]+/).includes(f.ruleId))) return false;
1177
+ }
1178
+ return true;
1179
+ });
1180
+ }
1181
+ };
1182
+
1183
+ // src/fileAnalyzer.ts
1184
+ import { readFile } from "fs/promises";
1185
+ import { join as join2 } from "path";
1186
+ var MAX_BYTES = 15e5;
1187
+ var QUICK_FILTER = /find|aggregate|query|execute|Query|Exec|objects|count|Count|update|Update|insert|Insert|delete|Delete|save|Save|create|Create|select|SELECT|First|Take|remove|upsert|distinct|Distinct/;
1188
+ async function analyzeFile(root, rel) {
1189
+ const lang = langForFile(rel);
1190
+ if (!lang) return { file: rel, findings: [] };
1191
+ try {
1192
+ const src = await readFile(join2(root, rel), "utf8");
1193
+ if (src.length > MAX_BYTES || !QUICK_FILTER.test(src)) return { file: rel, findings: [] };
1194
+ const findings = await analyzeSource(src, { relPath: rel, lang });
1195
+ return { file: rel, findings };
1196
+ } catch (e) {
1197
+ return { file: rel, findings: [], error: e.message };
1198
+ }
1199
+ }
1200
+
1201
+ export {
1202
+ RULES,
1203
+ RULE_MAP,
1204
+ findRule,
1205
+ SUPPORTED_EXTENSIONS,
1206
+ analyzeSource,
1207
+ analyzeFile
1208
+ };