querydig 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,7 @@ npx querydig # scan the current directory
12
12
  - **Fast.** Tree-sitter (WASM) parsing in a worker pool: about 2,000 files in under 1 second.
13
13
  - **Multi-language.** JavaScript, TypeScript, Python and Go.
14
14
  - **Multi-database.** MongoDB (Mongoose, native driver, PyMongo/Motor, mongo-go-driver) and SQL (Prisma, TypeORM, Sequelize, pg/mysql2/knex raw, Django ORM, SQLAlchemy, DB-API cursors, GORM/xorm, `database/sql`).
15
+ - **Live evidence (optional).** Point `--uri` at a MongoDB or Postgres replica to confirm full scans, missing and unused indexes, and slow queries.
15
16
  - **CI-ready.** `--fail-on`, baselines, `--diff`, SARIF for GitHub code scanning, Markdown for PR comments, HTML report.
16
17
 
17
18
  > Requires Node.js 22.12+.
@@ -91,6 +92,70 @@ npm i -D querydig
91
92
 
92
93
  Built-in noise control: `while`/`for {}` polling loops, batching loops (`skip`/`offset` in the loop), loops over collections or model classes, and array helpers like `arr.find(x => …)` or Python `str.find()` are not flagged.
93
94
 
95
+ ## Live mode: evidence from your database
96
+
97
+ Static analysis finds the patterns. Live mode connects **read-only** to MongoDB or Postgres and checks them against reality: how big each collection is, which indexes exist, and what the query planner actually does.
98
+
99
+ ```bash
100
+ npx querydig --uri "mongodb://reader:***@replica-host/shop"
101
+ QUERYDIG_DB_URI="postgres://reader:***@replica-host/shop" npx querydig # keeps the secret out of shell history
102
+ ```
103
+
104
+ What changes:
105
+
106
+ - **Promotes** an N+1 whose inner query is a full scan of a large table to critical ("N × full scan of 4.2M docs") and adds the missing index.
107
+ - **Demotes** unbounded reads on tiny lookup tables to low.
108
+ - **Adds live-only findings:**
109
+
110
+ | ID | Severity | Finds |
111
+ |---|---|---|
112
+ | LV001 | high / critical | Queries in your code that do a COLLSCAN / Seq Scan on 10k+ rows, with the exact index to create |
113
+ | LV002 | medium | Queries that sort in memory |
114
+ | LV003 | high / critical | Slow queries from the MongoDB profiler or `pg_stat_statements`, traced back to the line of code when possible |
115
+ | LV004 | low | Indexes that are never used |
116
+ | LV005 | medium | Postgres foreign keys without an index that your code filters on |
117
+
118
+ Every live finding shows its evidence:
119
+
120
+ ```text
121
+ CRITICAL QD002 Nested query over another query's results (N² risk) [mongodb]
122
+ src/reports.ts:15:25
123
+ live: Each iteration does a full scan of orders (300k docs): N × 300k docs read. (high → critical)
124
+ fix: … Until then, at least add an index: db.orders.createIndex({ userId: 1 })
125
+ ```
126
+
127
+ **Safety model**
128
+
129
+ - Only metadata and query plans are read by default: collection stats, index lists, `explain('queryPlanner')`, and `EXPLAIN (GENERIC_PLAN)` (PostgreSQL 16+). Queries are **planned, never executed**.
130
+ - `--explain-execute` opts into execution stats (`executionStats` / `EXPLAIN ANALYZE`, SELECTs only), which do run the query. Use it on a replica.
131
+ - Postgres: every statement runs in a read-only transaction (`default_transaction_read_only = on`) with a 5s statement timeout. MongoDB: `secondaryPreferred` reads with `maxTimeMS: 5000`.
132
+ - Passwords are redacted from all output. If the database can't be reached, querydig warns and still reports static findings (`--live-required` makes that an error).
133
+
134
+ **Recommended read-only users**
135
+
136
+ ```js
137
+ // MongoDB
138
+ db.createUser({ user: 'querydig', pwd: '…', roles: [{ role: 'read', db: 'shop' }, { role: 'clusterMonitor', db: 'admin' }] })
139
+ ```
140
+
141
+ ```sql
142
+ -- Postgres
143
+ CREATE ROLE querydig LOGIN PASSWORD '…';
144
+ GRANT pg_read_all_stats TO querydig;
145
+ GRANT SELECT ON ALL TABLES IN SCHEMA public TO querydig;
146
+ ```
147
+
148
+ Slow-query detection needs the MongoDB profiler (`db.setProfilingLevel(1, { slowms: 100 })`) or the `pg_stat_statements` extension. querydig tells you when either is missing.
149
+
150
+ | Option | Default | |
151
+ |---|---|---|
152
+ | `--uri <uri>` / `QUERYDIG_DB_URI` | | `mongodb://`, `mongodb+srv://`, `postgres://`, `postgresql://` |
153
+ | `--db <name>` | from URI | MongoDB database when the URI has none |
154
+ | `--slow-ms <n>` | 100 | Slow-query threshold |
155
+ | `--explain-execute` | off | Execute explain (replicas only) |
156
+ | `--max-explain <n>` | 200 | Cap on query shapes explained |
157
+ | `--live-required` | off | Exit 2 if the database can't be reached |
158
+
94
159
  ## Suppressing findings
95
160
 
96
161
  ```ts
@@ -165,7 +230,8 @@ console.log(summary.bySeverity, summary.grade);
165
230
 
166
231
  ## Roadmap
167
232
 
168
- - **Live mode** (`--uri`, read-only): MongoDB profiler + `explain()` (COLLSCAN, docs-examined ratio, in-memory SORT), missing/unused indexes; Postgres `pg_stat_statements` + `EXPLAIN`; MySQL `performance_schema`. Live evidence will be merged with static findings to promote severity.
233
+ - Live mode for MySQL (`performance_schema` + `EXPLAIN`).
234
+ - Repo-wide index recommendations and PR comments.
169
235
  - Java/Kotlin (Hibernate/Spring Data), Ruby (ActiveRecord), PHP (Eloquent).
170
236
  - An official GitHub Action and incremental cache.
171
237
 
@@ -1,6 +1,18 @@
1
- import type { Finding, LangId } from './types.js';
1
+ import type { Finding, LangId, QueryShape, SortKey } from './types.js';
2
2
  export interface AnalyzeOptions {
3
3
  relPath: string;
4
4
  lang: LangId;
5
5
  }
6
+ export interface AnalyzeResult {
7
+ findings: Finding[];
8
+ shapes: QueryShape[];
9
+ }
10
+ export declare function analyzeSourceFull(source: string, opts: AnalyzeOptions): Promise<AnalyzeResult>;
6
11
  export declare function analyzeSource(source: string, opts: AnalyzeOptions): Promise<Finding[]>;
12
+ /** Table name and WHERE / ORDER BY columns from a SQL string (best effort). */
13
+ export declare function sqlShape(sql: string): {
14
+ table?: string;
15
+ filterKeys: string[];
16
+ sortKeys: SortKey[];
17
+ hasLimit: boolean;
18
+ };
@@ -0,0 +1,26 @@
1
+ import {
2
+ analyzeSourceFull,
3
+ langForFile
4
+ } from "./chunk-YKUITQ6N.js";
5
+
6
+ // src/fileAnalyzer.ts
7
+ import { readFile } from "fs/promises";
8
+ import { join } from "path";
9
+ var MAX_BYTES = 15e5;
10
+ 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/;
11
+ async function analyzeFile(root, rel) {
12
+ const lang = langForFile(rel);
13
+ if (!lang) return { file: rel, findings: [] };
14
+ try {
15
+ const src = await readFile(join(root, rel), "utf8");
16
+ if (src.length > MAX_BYTES || !QUICK_FILTER.test(src)) return { file: rel, findings: [] };
17
+ const { findings, shapes } = await analyzeSourceFull(src, { relPath: rel, lang });
18
+ return { file: rel, findings, shapes };
19
+ } catch (e) {
20
+ return { file: rel, findings: [], error: e.message };
21
+ }
22
+ }
23
+
24
+ export {
25
+ analyzeFile
26
+ };
@@ -1,9 +1,11 @@
1
+ import {
2
+ analyzeFile
3
+ } from "./chunk-DZLU5MLV.js";
1
4
  import {
2
5
  RULES,
3
6
  RULE_MAP,
4
- SUPPORTED_EXTENSIONS,
5
- analyzeFile
6
- } from "./chunk-VAYEO4RI.js";
7
+ SUPPORTED_EXTENSIONS
8
+ } from "./chunk-YKUITQ6N.js";
7
9
 
8
10
  // src/config.ts
9
11
  import { existsSync, readFileSync } from "fs";
@@ -103,14 +105,14 @@ function renderSarif(s) {
103
105
  }))
104
106
  }
105
107
  },
106
- results: s.findings.map((f) => ({
108
+ results: s.findings.filter((f) => f.line > 0 && !f.file.startsWith("db://")).map((f) => ({
107
109
  ruleId: f.ruleId,
108
110
  ruleIndex: rules.findIndex((r) => r.id === f.ruleId),
109
111
  level: SARIF_LEVEL[f.severity],
110
112
  message: { text: `${f.message} Fix: ${f.fix}` },
111
113
  locations: [{ physicalLocation: { artifactLocation: { uri: f.file }, region: { startLine: f.line, startColumn: f.column } } }],
112
114
  partialFingerprints: { querydig: f.fingerprint },
113
- properties: { severity: f.severity, db: f.db, confidence: f.confidence }
115
+ properties: { severity: f.severity, db: f.db, confidence: f.confidence, ...f.evidence ? { evidence: f.evidence } : {} }
114
116
  }))
115
117
  }]
116
118
  };
@@ -121,7 +123,7 @@ function renderMarkdown(s, limit = 50) {
121
123
  const o = [];
122
124
  o.push(`## querydig \u2014 query performance report`);
123
125
  o.push("");
124
- o.push(`**Health: ${s.grade} (${s.score}/100)** \xB7 ${s.filesScanned} files \xB7 ${s.findings.length} issue(s)` + (s.suppressedByBaseline ? ` \xB7 ${s.suppressedByBaseline} hidden by baseline` : ""));
126
+ o.push(`**Health: ${s.grade} (${s.score}/100)** \xB7 ${s.filesScanned} files \xB7 ${s.findings.length} issue(s)` + (s.suppressedByBaseline ? ` \xB7 ${s.suppressedByBaseline} hidden by baseline` : "") + (s.live && !s.live.error ? ` \xB7 live: ${s.live.adapter} \`${s.live.database}\`, ${s.live.explained} plans` : ""));
125
127
  o.push("");
126
128
  o.push("| Severity | Count |");
127
129
  o.push("|---|---|");
@@ -134,7 +136,9 @@ function renderMarkdown(s, limit = 50) {
134
136
  o.push("| | Rule | Location | Issue |");
135
137
  o.push("|---|---|---|---|");
136
138
  for (const f of s.findings.slice(0, limit)) {
137
- o.push(`| ${EMOJI[f.severity]} | \`${f.ruleId}\` ${RULE_MAP.get(f.ruleId)?.name ?? ""} | \`${f.file}:${f.line}\` | ${f.message.replace(/\|/g, "\\|")} |`);
139
+ const loc = f.line > 0 ? `${f.file}:${f.line}` : f.file;
140
+ const ev = f.evidence ? `<br>\u{1F50E} ${f.evidence.note}${f.evidence.severityChange ? ` (${f.evidence.severityChange.from} \u2192 ${f.evidence.severityChange.to})` : ""}` : "";
141
+ o.push(`| ${EMOJI[f.severity]} | \`${f.ruleId}\` ${RULE_MAP.get(f.ruleId)?.name ?? ""} | \`${loc}\` | ${(f.message + ev).replace(/\|/g, "\\|")} |`);
138
142
  }
139
143
  if (s.findings.length > limit) o.push(`
140
144
  \u2026and ${s.findings.length - limit} more.`);
@@ -152,7 +156,7 @@ function renderMarkdown(s, limit = 50) {
152
156
  function renderHtml(s) {
153
157
  const used = new Set(s.findings.map((f) => f.ruleId));
154
158
  const data = {
155
- summary: { root: s.root, files: s.filesScanned, ms: s.durationMs, grade: s.grade, score: s.score, bySeverity: s.bySeverity, baseline: s.suppressedByBaseline, version: s.version, generated: (/* @__PURE__ */ new Date()).toISOString() },
159
+ summary: { root: s.root, files: s.filesScanned, ms: s.durationMs, grade: s.grade, score: s.score, bySeverity: s.bySeverity, baseline: s.suppressedByBaseline, version: s.version, generated: (/* @__PURE__ */ new Date()).toISOString(), live: s.live ?? null },
156
160
  findings: s.findings,
157
161
  rules: RULES.filter((r) => used.has(r.id))
158
162
  };
@@ -194,6 +198,8 @@ h1{font-size:22px;margin:0 0 4px;letter-spacing:-.01em}
194
198
  .loc{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:var(--muted);margin:4px 0 8px;word-break:break-all}
195
199
  pre{background:var(--code);border-radius:8px;padding:10px 12px;overflow-x:auto;margin:8px 0;font:12.5px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace}
196
200
  .fix{color:var(--ok)}
201
+ .ev{margin-top:6px;padding:6px 10px;border-radius:8px;background:color-mix(in oklab,#a855f7 12%,transparent);color:var(--ink);font-size:13px}
202
+ .ev b{color:#a855f7;font-weight:600;margin-right:6px}
197
203
  .tag{background:var(--chip);border-radius:6px;padding:1px 7px;font-size:11px;color:var(--muted)}
198
204
  details{margin-top:6px}summary{cursor:pointer;color:var(--muted)}
199
205
  .empty{text-align:center;color:var(--muted);padding:48px 0}
@@ -220,7 +226,7 @@ const D=${json};
220
226
  const SEV=['critical','high','medium','low','info'];
221
227
  const esc=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
222
228
  const S=D.summary, on=new Set(['critical','high','medium','low','info']);
223
- document.getElementById('sub').textContent=S.root+' \xB7 '+S.files+' files \xB7 '+(S.ms/1000).toFixed(2)+'s'+(S.baseline?' \xB7 '+S.baseline+' hidden by baseline':'');
229
+ document.getElementById('sub').textContent=S.root+' \xB7 '+S.files+' files \xB7 '+(S.ms/1000).toFixed(2)+'s'+(S.baseline?' \xB7 '+S.baseline+' hidden by baseline':'')+(S.live&&!S.live.error?' \xB7 live: '+S.live.adapter+' '+S.live.database+', '+S.live.explained+' query plans':'');
224
230
  const gc={A:'var(--ok)',B:'var(--ok)',C:'var(--med)',D:'var(--high)',F:'var(--crit)'}[S.grade];
225
231
  document.getElementById('cards').innerHTML='<div class="card grade"><div class="k">Health</div><div class="v" style="color:'+gc+'">'+S.grade+' <small style="font-size:14px;color:var(--muted)">'+S.score+'/100</small></div></div>'+
226
232
  ['critical','high','medium','low'].map(k=>'<div class="card"><div class="k">'+k+'</div><div class="v c-'+k+'">'+S.bySeverity[k]+'</div></div>').join('');
@@ -238,7 +244,7 @@ function render(){
238
244
  const L=document.getElementById('list');
239
245
  if(!fs.length){L.innerHTML='<div class="empty">'+(D.findings.length?'No findings match the filters.':'\u2714 No query performance issues found.')+'</div>';return}
240
246
  L.innerHTML=fs.slice(0,1000).map(f=>{const r=RM[f.ruleId]||{};return '<div class="f '+f.severity+'"><h3><span class="sev c-'+f.severity+'">'+f.severity+'</span><span class="rid">'+f.ruleId+'</span>'+esc(f.title)+' <span class="tag">'+f.db+'</span><span class="tag">'+f.confidence+' confidence</span></h3>'+
241
- '<div class="loc">'+esc(f.file)+':'+f.line+':'+f.column+'</div><pre>'+esc(f.snippet)+'</pre><div>'+esc(f.message)+'</div><div class="fix"><b>Fix:</b> '+esc(f.fix)+'</div>'+
247
+ '<div class="loc">'+esc(f.file)+(f.line>0?':'+f.line+':'+f.column:'')+'</div><pre>'+esc(f.snippet)+'</pre><div>'+esc(f.message)+'</div>'+(f.evidence?'<div class="ev"><b>live</b>'+esc(f.evidence.note)+(f.evidence.severityChange?' ('+f.evidence.severityChange.from+' \u2192 '+f.evidence.severityChange.to+')':'')+'</div>':'')+'<div class="fix"><b>Fix:</b> '+esc(f.fix)+'</div>'+
242
248
  (r.why?'<details><summary>Why is this slow?</summary><p>'+esc(r.why)+'</p>'+(r.example?'<div>Before</div><pre>'+esc(r.example.bad)+'</pre><div>After</div><pre>'+esc(r.example.good)+'</pre>':'')+'</details>':'')+'</div>'}).join('')+(fs.length>1000?'<div class="empty">Showing first 1000 of '+fs.length+'</div>':'');
243
249
  }
244
250
  ['q','db'].forEach(id=>document.getElementById(id).addEventListener('input',render));rsel.addEventListener('input',render);
@@ -266,6 +272,10 @@ function renderTerminal(s, opts = {}) {
266
272
  out.push("");
267
273
  out.push(pc.bold(`querydig ${pc.dim("v" + s.version)} \u2014 slow-query scan of ${s.root}`));
268
274
  out.push(pc.dim(`${s.filesScanned} files in ${(s.durationMs / 1e3).toFixed(2)}s`));
275
+ if (s.live && !s.live.error) {
276
+ out.push(pc.dim(`live: ${s.live.adapter} ${s.live.database} \xB7 ${s.live.tables} ${s.live.adapter === "mongodb" ? "collections" : "tables"} \xB7 ${s.live.explained} query plans \xB7 ${s.live.slowQueries} slow queries`));
277
+ }
278
+ for (const w of s.live?.warnings ?? []) out.push(pc.yellow(` ! ${w}`));
269
279
  out.push("");
270
280
  if (!s.findings.length) {
271
281
  out.push(pc.green(pc.bold("\u2714 No query performance issues found.")));
@@ -297,9 +307,13 @@ function renderFinding(f, verbose) {
297
307
  const lines = [];
298
308
  const db = f.db === "unknown" ? "" : pc.dim(` [${f.db}]`);
299
309
  lines.push(`${SEV_COLOR[f.severity](` ${f.severity.toUpperCase()} `)} ${pc.bold(f.ruleId)} ${f.title}${db}`);
300
- lines.push(` ${pc.underline(`${f.file}:${f.line}:${f.column}`)}`);
310
+ lines.push(` ${pc.underline(f.line > 0 ? `${f.file}:${f.line}:${f.column}` : f.file)}`);
301
311
  for (const l of f.snippet.split("\n").slice(0, 3)) lines.push(pc.dim(" \u2502 ") + l);
302
312
  lines.push(` ${f.message}`);
313
+ if (f.evidence) {
314
+ const sc = f.evidence.severityChange ? ` ${pc.bold(`(${f.evidence.severityChange.from} \u2192 ${f.evidence.severityChange.to})`)}` : "";
315
+ lines.push(` ${pc.magenta("live:")} ${f.evidence.note}${sc}`);
316
+ }
303
317
  lines.push(` ${pc.green("fix:")} ${f.fix}`);
304
318
  if (verbose) lines.push(pc.dim(` confidence: ${f.confidence} fingerprint: ${f.fingerprint}`));
305
319
  lines.push("");
@@ -311,7 +325,7 @@ var SEVERITIES = ["critical", "high", "medium", "low", "info"];
311
325
  var SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
312
326
 
313
327
  // src/version.ts
314
- var VERSION = true ? "0.1.1" : "0.0.0-dev";
328
+ var VERSION = true ? "0.2.0" : "0.0.0-dev";
315
329
 
316
330
  // src/scan.ts
317
331
  import { execFileSync } from "child_process";
@@ -406,11 +420,21 @@ async function scan(opts) {
406
420
  const threads = opts.threads ?? Math.max(1, availableParallelism() - 1);
407
421
  const results = await runPool(root, files, threads, opts.onProgress);
408
422
  let findings = [];
423
+ const shapes = [];
409
424
  const errors = [];
410
425
  for (const r of results) {
411
426
  if (!r) continue;
412
427
  if (r.error) errors.push({ file: r.file, error: r.error });
413
428
  findings.push(...r.findings);
429
+ if (r.shapes) shapes.push(...r.shapes);
430
+ }
431
+ let live;
432
+ if (opts.live) {
433
+ opts.onLiveStart?.();
434
+ const { runLive } = await import("./live-DSIYWIT4.js");
435
+ const res = await runLive(findings, shapes, opts.live);
436
+ findings = res.findings;
437
+ live = res.summary;
414
438
  }
415
439
  if (opts.rules) {
416
440
  findings = findings.flatMap((f) => {
@@ -454,7 +478,8 @@ async function scan(opts) {
454
478
  score,
455
479
  suppressedByBaseline,
456
480
  errors,
457
- version: VERSION
481
+ version: VERSION,
482
+ ...live ? { live } : {}
458
483
  };
459
484
  }
460
485
 
@@ -215,6 +215,52 @@ var RULES = [
215
215
  db: "sql",
216
216
  why: "NOT IN with a subquery is often executed as a nested loop and has surprising NULL semantics.",
217
217
  fix: "Rewrite as NOT EXISTS or a LEFT JOIN ... WHERE x IS NULL."
218
+ }),
219
+ // ---------- live database (--uri) ----------
220
+ r({
221
+ id: "LV001",
222
+ name: "full-scan",
223
+ title: "Query does a full collection / table scan",
224
+ severity: "high",
225
+ db: "any",
226
+ why: "The live query plan shows COLLSCAN / Seq Scan: every document or row is read to answer this query. Cost grows linearly with the table and competes with every other query for I/O and cache.",
227
+ fix: "Create an index that matches the filter (equality fields first), then the sort fields, then range fields."
228
+ }),
229
+ r({
230
+ id: "LV002",
231
+ name: "in-memory-sort",
232
+ title: "Query sorts in memory",
233
+ severity: "medium",
234
+ db: "any",
235
+ why: "The plan contains a blocking SORT stage: all matching rows are buffered and sorted before the first one is returned.",
236
+ fix: "Add the sort fields to an index after the equality fields, in the same order and direction as the sort."
237
+ }),
238
+ r({
239
+ id: "LV003",
240
+ name: "slow-query",
241
+ title: "Slow query recorded by the database",
242
+ severity: "high",
243
+ db: "any",
244
+ why: "The database profiler / pg_stat_statements recorded this query shape above the slow threshold in real traffic.",
245
+ fix: "Check its plan with explain(), add a matching index, or reduce the documents/rows it has to read."
246
+ }),
247
+ r({
248
+ id: "LV004",
249
+ name: "unused-index",
250
+ title: "Index is never used",
251
+ severity: "low",
252
+ db: "any",
253
+ why: "The index has zero recorded uses since statistics were last reset, but every write still has to update it and it takes RAM.",
254
+ fix: "Confirm across all replicas and a full business cycle, then drop it."
255
+ }),
256
+ r({
257
+ id: "LV005",
258
+ name: "missing-fk-index",
259
+ title: "Foreign key column has no index",
260
+ severity: "medium",
261
+ db: "sql",
262
+ why: "Queries that filter or join on this foreign key, and deletes on the parent table, have to scan the whole child table.",
263
+ fix: "CREATE INDEX CONCURRENTLY on the foreign key column."
218
264
  })
219
265
  ];
220
266
  var RULE_MAP = new Map(RULES.map((x) => [x.id, x]));
@@ -528,16 +574,30 @@ var BUILTIN_ROOTS = /* @__PURE__ */ new Set([
528
574
  var DEFAULT_TEST_LOOP_BREAKERS = /* @__PURE__ */ new Set(["while_statement", "do_statement"]);
529
575
  var same = (a, b) => !!a && !!b && a.startIndex === b.startIndex && a.endIndex === b.endIndex && a.type === b.type;
530
576
  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 [];
577
+ async function analyzeSourceFull(source, opts) {
578
+ if (/querydig-ignore-file/.test(source.slice(0, 2e3))) return { findings: [], shapes: [] };
533
579
  const parser = await getParser(opts.lang);
534
580
  const tree = parser.parse(source);
535
581
  try {
536
- return new Analyzer(source, opts, LANGS[opts.lang]).run(tree.rootNode);
582
+ const a = new Analyzer(source, opts, LANGS[opts.lang]);
583
+ const findings = a.run(tree.rootNode);
584
+ return { findings, shapes: a.shapes };
537
585
  } finally {
538
586
  tree.delete();
539
587
  }
540
588
  }
589
+ async function analyzeSource(source, opts) {
590
+ return (await analyzeSourceFull(source, opts)).findings;
591
+ }
592
+ function sqlShape(sql) {
593
+ const unq = (x) => x.replace(/["`\[\]]/g, "").split(".").pop();
594
+ const table = /\b(?:from|update|into)\s+([\w."`\[\]]+)/i.exec(sql)?.[1];
595
+ const where = /\bwhere\b([\s\S]*?)(\border\s+by\b|\bgroup\s+by\b|\blimit\b|\boffset\b|\breturning\b|$)/i.exec(sql)?.[1] ?? "";
596
+ const filterKeys = [...where.matchAll(/([\w."`]+)\s*(?:=|<>|!=|<=|>=|<|>|\bin\b|\blike\b|\bilike\b|\bis\b|\bbetween\b)/gi)].map((m) => unq(m[1])).filter((k) => !/^(and|or|not|\d+)$/i.test(k));
597
+ const order = /\border\s+by\s+([\s\S]*?)(\blimit\b|\boffset\b|$)/i.exec(sql)?.[1] ?? "";
598
+ const sortKeys = order.split(",").map((p) => p.trim()).filter((p) => /^[\w."`]+/.test(p) && !/\(/.test(p)).map((p) => ({ key: unq(p.split(/\s+/)[0]), dir: /\bdesc\b/i.test(p) ? -1 : 1 }));
599
+ return { table: table ? unq(table) : void 0, filterKeys: [...new Set(filterKeys)], sortKeys, hasLimit: /\blimit\b|\bfetch\s+first\b/i.test(sql) };
600
+ }
541
601
  var Analyzer = class {
542
602
  constructor(src, opts, spec) {
543
603
  this.src = src;
@@ -554,6 +614,7 @@ var Analyzer = class {
554
614
  spec;
555
615
  lines;
556
616
  findings = [];
617
+ shapes = [];
557
618
  queryVars = /* @__PURE__ */ new Set();
558
619
  hasMongo;
559
620
  hasSql;
@@ -846,6 +907,7 @@ var Analyzer = class {
846
907
  // ------------------------------------------------------------- rules
847
908
  evaluate(q, ctx) {
848
909
  this.trackAssignment(q);
910
+ this.recordShape(q, ctx);
849
911
  const target = q.collection ? `${q.collection}.${q.method}` : `${q.receiver}.${q.method}`;
850
912
  const loopInfo = ctx.loopLine ? ` (loop at line ${ctx.loopLine})` : "";
851
913
  const paginating = q.chain.some((c) => /^(skip|Skip|offset|Offset)$/.test(c.name) || /^(Limit)$/.test(c.name) && c.args.includes(","));
@@ -875,6 +937,80 @@ var Analyzer = class {
875
937
  if (q.db === "mongodb") this.mongoRules(q);
876
938
  if (q.db === "sql") this.sqlOrmRules(q);
877
939
  }
940
+ recordShape(q, ctx) {
941
+ const id = `${this.opts.relPath}:${q.node.startPosition.row + 1}:${q.node.startPosition.column + 1}`;
942
+ q.shapeId = id;
943
+ let filterKeys = this.shapeFilterKeys(q);
944
+ let sortKeys = this.sortKeys(q);
945
+ let collection = q.collection;
946
+ let sql;
947
+ if (q.kind === "raw" && q.args[0] && this.spec.strings.includes(q.args[0].type)) {
948
+ sql = stripQuotes(q.args[0].text.replace(/\$\{[^}]*\}/g, "?")).replace(/\s+/g, " ").trim();
949
+ const sh = sqlShape(sql);
950
+ collection = sh.table;
951
+ filterKeys = sh.filterKeys;
952
+ sortKeys = sh.sortKeys;
953
+ }
954
+ const hasLimit = q.kind === "read" || this.chainHas(q, /^(limit|Limit|take|first|First|one|get|Take|Last)$/) || /\b(limit|take|SetLimit|first)\b/.test(q.argsText) || !!sql && /\blimit\b/i.test(sql);
955
+ this.shapes.push({
956
+ id,
957
+ db: q.db,
958
+ orm: q.orm,
959
+ collection,
960
+ method: q.method,
961
+ kind: q.kind,
962
+ filterKeys,
963
+ sortKeys,
964
+ hasLimit,
965
+ inLoop: ctx.depth > 0,
966
+ sql,
967
+ file: this.opts.relPath,
968
+ line: q.node.startPosition.row + 1
969
+ });
970
+ }
971
+ shapeFilterKeys(q) {
972
+ const clean = (k) => k.split("__")[0];
973
+ if (q.orm === "prisma" && q.args[0]?.type === "object") {
974
+ const where = q.args[0].namedChildren.find((c) => c.type === "pair" && c.childForFieldName("key")?.text === "where");
975
+ const v = where?.childForFieldName("value");
976
+ return v ? this.objectKeys(v) : [];
977
+ }
978
+ if (this.spec.id === "python" && (q.orm === "django" || q.orm === "sqlalchemy")) {
979
+ const txt = [q.argsText, ...q.chain.filter((c) => /^(filter|filter_by|get|exclude)$/.test(c.name)).map((c) => c.args)].join(" ");
980
+ return [...new Set([...txt.matchAll(/(?:^|[(,\s])([a-z_][\w]*)\s*=(?!=)/gi)].map((m) => clean(m[1])))];
981
+ }
982
+ if (q.orm === "gorm" || this.spec.id === "go") {
983
+ const w = q.chain.filter((c) => /^(Where|And|Or)$/.test(c.name)).map((c) => c.args).join(" ");
984
+ if (w) return sqlShape("select 1 from t where " + stripQuotes(w.replace(/^\(|\)$/g, "").split(",")[0].trim())).filterKeys;
985
+ }
986
+ if (/^(findById|findByIdAndUpdate|findByIdAndDelete|findByIdAndRemove|findByPk|UpdateByID|findUnique)$/.test(q.method) && !this.filterKeys(q).length) return q.db === "mongodb" ? ["_id"] : ["id"];
987
+ return this.filterKeys(q);
988
+ }
989
+ /** Sort keys from .sort({ a: -1 }), .sort("a", -1), sort=[("a", -1)], SetSort(bson.D{{"a", -1}}), order_by("-a"). */
990
+ sortKeys(q) {
991
+ const out = [];
992
+ const dirOf = (v) => v && /^(-1|['"]?desc(ending)?['"]?|pymongo\.DESCENDING|DESCENDING)$/i.test(v.trim()) ? -1 : 1;
993
+ const links = q.chain.filter((c) => /^(sort|Sort|order_by|orderBy|Order)$/.test(c.name)).map((c) => c.args);
994
+ const fromArgs = /\bSetSort\(([^)]*\))|\bsort\s*=\s*(\[[^\]]*\])/.exec(q.argsText);
995
+ if (fromArgs) links.push(fromArgs[1] ?? fromArgs[2]);
996
+ const prismaOrder = /orderBy\s*:\s*(\{[^}]*\}|\[[^\]]*\])/.exec(q.argsText);
997
+ if (prismaOrder) links.push(prismaOrder[1]);
998
+ for (const txt of links) {
999
+ const obj = /\{([^{}]*)\}/.exec(txt);
1000
+ if (obj && /:/.test(obj[1])) {
1001
+ for (const part of obj[1].split(",")) {
1002
+ const [k, v] = part.split(":").map((x) => x.trim().replace(/['"]/g, ""));
1003
+ if (k && /^[\w.]+$/.test(k)) out.push({ key: k, dir: dirOf(v) });
1004
+ }
1005
+ continue;
1006
+ }
1007
+ for (const m of txt.matchAll(/["'](-?)([\w.]+)["']\s*(?:,\s*(-?1|pymongo\.\w+|[A-Z]+))?/g)) {
1008
+ out.push({ key: m[2], dir: m[1] === "-" ? -1 : dirOf(m[3]) });
1009
+ }
1010
+ }
1011
+ const seen = /* @__PURE__ */ new Set();
1012
+ return out.filter((k) => !seen.has(k.key) && seen.add(k.key));
1013
+ }
878
1014
  /** `collections.map(c => c.deleteMany({}))` hits a different collection each time: not N+1. */
879
1015
  targetVariesPerIteration(q, ctx) {
880
1016
  const vars = ctx.loopVars ?? [];
@@ -1122,6 +1258,12 @@ var Analyzer = class {
1122
1258
  if (/^(delete)\b/i.test(txt) && !/\bfrom\b/i.test(txt)) return;
1123
1259
  if (/^insert\b/i.test(txt) && !/\binto\b/i.test(txt)) return;
1124
1260
  const pseudo = { node, top: node, method: "sql", kind: "raw", db: "sql", receiver: "sql", receiverText: "", args: [], argsText: txt, chain: [], confidence: "high" };
1261
+ if (!dynamic) {
1262
+ const sh = sqlShape(txt);
1263
+ const id = `${this.opts.relPath}:${node.startPosition.row + 1}:${node.startPosition.column + 1}:sql`;
1264
+ pseudo.shapeId = id;
1265
+ this.shapes.push({ id, db: "sql", collection: sh.table, method: "sql", kind: /^select|^with/i.test(txt) ? "readMany" : "write", filterKeys: sh.filterKeys, sortKeys: sh.sortKeys, hasLimit: sh.hasLimit, inLoop: false, sql: txt, file: this.opts.relPath, line: node.startPosition.row + 1 });
1266
+ }
1125
1267
  const concatenated = !!node.parent && ["binary_expression", "concatenated_string", "binary_operator"].includes(node.parent.type);
1126
1268
  if (/^select\s+(\w+\.)?\*\s+from/i.test(txt)) this.add("SQ002", node, pseudo, { message: `\`${txt.slice(0, 60)}\` selects every column.` });
1127
1269
  if (/\b(i?like)\s+['"]%/i.test(txt) || /\b(i?like)\s+(concat\(\s*)?['"]%['"]\s*(\|\||,|\+)/i.test(txt)) {
@@ -1165,7 +1307,8 @@ var Analyzer = class {
1165
1307
  message: o.message,
1166
1308
  fix: o.fix ?? rule.fix,
1167
1309
  confidence: o.confidence ?? q.confidence,
1168
- fingerprint
1310
+ fingerprint,
1311
+ ...q.shapeId ? { shapeId: q.shapeId } : {}
1169
1312
  });
1170
1313
  }
1171
1314
  applySuppressions() {
@@ -1180,29 +1323,13 @@ var Analyzer = class {
1180
1323
  }
1181
1324
  };
1182
1325
 
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
1326
  export {
1202
1327
  RULES,
1203
1328
  RULE_MAP,
1204
1329
  findRule,
1330
+ langForFile,
1205
1331
  SUPPORTED_EXTENSIONS,
1332
+ analyzeSourceFull,
1206
1333
  analyzeSource,
1207
- analyzeFile
1334
+ sqlShape
1208
1335
  };
package/dist/cli.js CHANGED
@@ -11,11 +11,12 @@ import {
11
11
  renderSarif,
12
12
  renderTerminal,
13
13
  scan
14
- } from "./chunk-WTZLJ5ZV.js";
14
+ } from "./chunk-P3O2JP5S.js";
15
+ import "./chunk-DZLU5MLV.js";
15
16
  import {
16
17
  RULES,
17
18
  findRule
18
- } from "./chunk-VAYEO4RI.js";
19
+ } from "./chunk-YKUITQ6N.js";
19
20
 
20
21
  // src/cli.ts
21
22
  import { existsSync as existsSync2, writeFileSync as writeFileSync2 } from "fs";
@@ -44,7 +45,7 @@ function writeBaseline(path, findings) {
44
45
  // src/cli.ts
45
46
  var program = new Command();
46
47
  program.name("querydig").description("Find slow and unoptimized database queries (MongoDB, SQL) in JS/TS, Python and Go code.").version(VERSION);
47
- program.command("scan", { isDefault: true }).description("Scan a project for slow query patterns").argument("[path]", "project root", ".").addOption(new Option("-f, --format <format>", "output format").choices(["terminal", "json", "sarif", "md", "html"]).default("terminal")).option("-o, --output <file>", "write report to a file (default for html: querydig-report.html)").addOption(new Option("--fail-on <severity>", "exit with code 1 if any finding is at or above this severity").choices([...SEVERITIES, "none"])).addOption(new Option("--min-severity <severity>", "hide findings below this severity").choices(SEVERITIES)).option("--baseline <file>", "ignore findings recorded in this baseline file").option("--update-baseline [file]", "write all current findings to a baseline file and exit 0").option("--diff <ref>", "only scan files changed since a git ref (e.g. origin/main)").option("--include-tests", "also scan tests, fixtures, migrations and seeds", false).option("--ignore <globs...>", "extra glob patterns to ignore").option("--rules <list>", "only report these rule ids (comma separated)").option("--disable <list>", "disable these rule ids (comma separated)").option("-c, --config <file>", "path to a config file").option("--limit <n>", "max findings shown in terminal (0 = all)", "25").option("--threads <n>", "worker threads").option("-v, --verbose", "show confidence, fingerprints and parse errors", false).action(async (path, o) => {
48
+ program.command("scan", { isDefault: true }).description("Scan a project for slow query patterns").argument("[path]", "project root", ".").addOption(new Option("-f, --format <format>", "output format").choices(["terminal", "json", "sarif", "md", "html"]).default("terminal")).option("-o, --output <file>", "write report to a file (default for html: querydig-report.html)").addOption(new Option("--fail-on <severity>", "exit with code 1 if any finding is at or above this severity").choices([...SEVERITIES, "none"])).addOption(new Option("--min-severity <severity>", "hide findings below this severity").choices(SEVERITIES)).option("--baseline <file>", "ignore findings recorded in this baseline file").option("--update-baseline [file]", "write all current findings to a baseline file and exit 0").option("--diff <ref>", "only scan files changed since a git ref (e.g. origin/main)").option("--include-tests", "also scan tests, fixtures, migrations and seeds", false).option("--ignore <globs...>", "extra glob patterns to ignore").option("--rules <list>", "only report these rule ids (comma separated)").option("--disable <list>", "disable these rule ids (comma separated)").option("-c, --config <file>", "path to a config file").option("--limit <n>", "max findings shown in terminal (0 = all)", "25").option("--threads <n>", "worker threads").option("-v, --verbose", "show confidence, fingerprints and parse errors", false).option("--uri <uri>", "read-only connection to MongoDB or Postgres for live evidence (or set QUERYDIG_DB_URI)").option("--db <name>", "MongoDB database name when the URI has none").option("--slow-ms <n>", "slow-query threshold for profiler / pg_stat_statements", "100").option("--explain-execute", "run explain with execution stats (executes queries; use a replica)", false).option("--max-explain <n>", "maximum number of query shapes to explain", "200").option("--live-required", "fail (exit 2) if the live database cannot be reached", false).action(async (path, o) => {
48
49
  const root = resolve(path);
49
50
  if (!existsSync2(root)) {
50
51
  console.error(pc.red(`querydig: path not found: ${root}`));
@@ -61,6 +62,8 @@ program.command("scan", { isDefault: true }).description("Scan a project for slo
61
62
  const updating = o.updateBaseline !== void 0;
62
63
  const baseline = !updating && baselinePath ? readBaseline(baselinePath) : void 0;
63
64
  if (baselinePath && !updating && !baseline) console.error(pc.yellow(`querydig: baseline ${baselinePath} not found \u2014 reporting all findings`));
65
+ const uri = o.uri ?? process.env.QUERYDIG_DB_URI;
66
+ const live = uri ? { uri, db: o.db, slowMs: Number(o.slowMs) || 100, explainExecute: !!o.explainExecute, maxExplain: Number(o.maxExplain) || 200, timeoutMs: 5e3 } : void 0;
64
67
  const interactive = o.format === "terminal" && process.stderr.isTTY;
65
68
  let last = 0;
66
69
  const summary = await scan({
@@ -73,6 +76,8 @@ program.command("scan", { isDefault: true }).description("Scan a project for slo
73
76
  diff: o.diff,
74
77
  baseline,
75
78
  threads: o.threads ? Number(o.threads) : void 0,
79
+ live,
80
+ onLiveStart: live && process.stderr.isTTY ? () => process.stderr.write(pc.dim("\rconnecting to the database for live evidence\u2026") + "\r") : void 0,
76
81
  onProgress: interactive ? (d, t) => {
77
82
  const now = Date.now();
78
83
  if (now - last > 80 || d === t) {
@@ -81,6 +86,11 @@ program.command("scan", { isDefault: true }).description("Scan a project for slo
81
86
  }
82
87
  } : void 0
83
88
  });
89
+ if (live && process.stderr.isTTY) process.stderr.write("\x1B[K");
90
+ if (summary.live?.error) {
91
+ console.error(pc.yellow(`querydig: live mode skipped: ${summary.live.error}`));
92
+ if (o.liveRequired) process.exit(2);
93
+ }
84
94
  if (updating) {
85
95
  const file = resolve(typeof o.updateBaseline === "string" ? o.updateBaseline : baselinePath ?? ".querydig-baseline.json");
86
96
  const n = writeBaseline(file, summary.findings);
package/dist/index.js CHANGED
@@ -6,12 +6,13 @@ import {
6
6
  renderSarif,
7
7
  renderTerminal,
8
8
  scan
9
- } from "./chunk-WTZLJ5ZV.js";
9
+ } from "./chunk-P3O2JP5S.js";
10
+ import "./chunk-DZLU5MLV.js";
10
11
  import {
11
12
  RULES,
12
13
  analyzeSource,
13
14
  findRule
14
- } from "./chunk-VAYEO4RI.js";
15
+ } from "./chunk-YKUITQ6N.js";
15
16
  export {
16
17
  RULES,
17
18
  analyzeSource,
@@ -0,0 +1,14 @@
1
+ import type { Finding, QueryShape } from '../types.js';
2
+ import type { LiveData } from './types.js';
3
+ export declare const THRESHOLDS: {
4
+ big: number;
5
+ huge: number;
6
+ scanMin: number;
7
+ tiny: number;
8
+ criticalMs: number;
9
+ };
10
+ /**
11
+ * Merge live database evidence into static findings and add live-only findings.
12
+ * Pure: no I/O, so it can be unit tested with fake LiveData.
13
+ */
14
+ export declare function correlate(findings: Finding[], shapes: QueryShape[], data: LiveData): Finding[];
@@ -0,0 +1,13 @@
1
+ import type { Finding, LiveSummary, QueryShape } from '../types.js';
2
+ import type { LiveAdapter, LiveData, LiveOptions } from './types.js';
3
+ export type { LiveOptions } from './types.js';
4
+ export { correlate } from './correlate.js';
5
+ export { redactUri } from './match.js';
6
+ export declare function adapterFor(opts: LiveOptions): LiveAdapter;
7
+ /** Connect, collect evidence for the static shapes, and return it. Always closes the connection. */
8
+ export declare function collectLive(shapes: QueryShape[], opts: LiveOptions, adapter?: LiveAdapter): Promise<LiveData>;
9
+ /** Collect live evidence and merge it into the findings. */
10
+ export declare function runLive(findings: Finding[], shapes: QueryShape[], opts: LiveOptions): Promise<{
11
+ findings: Finding[];
12
+ summary: LiveSummary;
13
+ }>;