orm-doctor 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # orm-doctor
2
+
3
+ Static analysis CLI for **ORM and database bottlenecks** in TypeScript and Prisma/Drizzle codebases.
4
+
5
+ Built by [NoctisNova](https://noctisnova.com).
6
+
7
+ ## Install & run
8
+
9
+ No install required:
10
+
11
+ ```bash
12
+ npx orm-doctor
13
+ npx orm-doctor ./my-app
14
+ npx orm-doctor --json
15
+ npx orm-doctor --no-ai
16
+ ```
17
+
18
+ Global install (optional):
19
+
20
+ ```bash
21
+ npm install -g orm-doctor
22
+ orm-doctor
23
+ ```
24
+
25
+ ## What it detects
26
+
27
+ - **N+1 queries** — DB calls inside loops
28
+ - **Missing indexes** — foreign keys without `@@index` in Prisma schema
29
+ - **Unsafe raw SQL** — `$queryRawUnsafe` / dynamic raw queries
30
+ - **Mass mutations** — `updateMany` / `deleteMany` without `where`
31
+ - **Unbounded queries** — `findMany()` without `take` or cursor
32
+ - **Prisma singleton** — multiple `new PrismaClient()` instances
33
+ - **Missing transactions** — multiple writes without `$transaction`
34
+ - **Risky relations** — missing `onDelete` referential actions
35
+ - **Seed issues** — slow seeds, hardcoded IDs, missing truncate
36
+
37
+ Produces a scored health report (0–100) and saves `.orm-doctor-report.json` for AI-assisted fixes.
38
+
39
+ ## Requirements
40
+
41
+ - Node.js **18+**
42
+
43
+ ## Links
44
+
45
+ - **Homepage:** https://noctisnova.com
46
+ - **Repository:** https://github.com/NoctisNovaStudio/orm-doctor
47
+ - **Issues:** https://github.com/NoctisNovaStudio/orm-doctor/issues
48
+
49
+ ## License
50
+
51
+ MIT
package/index.js ADDED
@@ -0,0 +1,502 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * index.js
4
+ * orm-doctor — ORM static analysis CLI
5
+ *
6
+ * Orchestrates multi-phase scanning with animated UI, dead-code filtering,
7
+ * a score reveal animation, and an arrow-key agent hand-off menu.
8
+ */
9
+
10
+ import * as p from "@clack/prompts";
11
+ import chalk from "chalk";
12
+ import clipboardy from "clipboardy";
13
+ import boxen from "boxen";
14
+ import { execSync } from "node:child_process";
15
+ import fs from "node:fs";
16
+ import path from "node:path";
17
+ import { parseArgs } from "node:util";
18
+
19
+ import {
20
+ scanForNPlusOne,
21
+ scanForMissingIndexes,
22
+ scanSeedFiles,
23
+ collectTypeScriptFiles,
24
+ isDeadCode,
25
+ } from "./src/scanner.js";
26
+
27
+ import {
28
+ runAdvancedScans,
29
+ detectStack,
30
+ } from "./src/advanced.js";
31
+
32
+ import {
33
+ renderProgressBar,
34
+ renderScoreBadge,
35
+ renderDashboard,
36
+ renderStackLine,
37
+ buildAgentPrompt,
38
+ } from "./src/ui.js";
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Constants
42
+ // ---------------------------------------------------------------------------
43
+
44
+ const REPORT_FILE = "./.orm-doctor-report.json";
45
+ const VERSION = "1.0.0";
46
+
47
+ const HELP_TEXT = `
48
+ ${chalk.bold("orm-doctor")} v${VERSION}
49
+ Static analysis CLI for ORM/database bottleneck detection.
50
+
51
+ ${chalk.bold("Usage")}
52
+ orm-doctor [options] [path]
53
+
54
+ ${chalk.bold("Arguments")}
55
+ path Root directory to scan (default: current working directory)
56
+
57
+ ${chalk.bold("Options")}
58
+ --schema, -s <path> Path to schema.prisma or its directory
59
+ --json Print the JSON report to stdout (CI mode)
60
+ --no-ai Skip the agent hand-off menu
61
+ --version, -v Print version and exit
62
+ --help, -h Show this help message
63
+
64
+ ${chalk.bold("What it detects")}
65
+ ${chalk.red("Security")} SQL injection via $queryRawUnsafe / string-built SQL
66
+ ${chalk.red("Data safety")} updateMany/deleteMany with no WHERE (table wipe)
67
+ dependent writes not wrapped in a transaction
68
+ ${chalk.yellow("Performance")} N+1 queries in loops · findMany() with no pagination
69
+ ${chalk.yellow("Connections")} new PrismaClient() with no global singleton guard
70
+ ${chalk.yellow("Schema")} foreign keys with no @@index · relations with no onDelete
71
+ ${chalk.blue("Seeds")} hardcoded IDs · no truncate · no $disconnect · huge batches
72
+
73
+ ${chalk.bold("Examples")}
74
+ orm-doctor
75
+ orm-doctor ./my-project --schema ./my-project/prisma
76
+ orm-doctor --json > report.json
77
+ `.trim();
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Utilities
81
+ // ---------------------------------------------------------------------------
82
+
83
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
84
+
85
+ function easeOut(t) {
86
+ return 1 - Math.pow(1 - t, 3);
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // CLI argument parsing
91
+ // ---------------------------------------------------------------------------
92
+
93
+ function parseCLIArgs() {
94
+ let parsed;
95
+ try {
96
+ parsed = parseArgs({
97
+ allowPositionals: true,
98
+ options: {
99
+ schema: { type: "string", short: "s" },
100
+ json: { type: "boolean", default: false },
101
+ "no-ai": { type: "boolean", default: false },
102
+ version: { type: "boolean", short: "v", default: false },
103
+ help: { type: "boolean", short: "h", default: false },
104
+ },
105
+ });
106
+ } catch (err) {
107
+ console.error(chalk.red(`Error: ${err.message}`));
108
+ process.exit(1);
109
+ }
110
+ return {
111
+ projectPath: parsed.positionals[0] ?? process.cwd(),
112
+ schemaPath: parsed.values.schema ?? null,
113
+ jsonMode: parsed.values.json,
114
+ noAi: parsed.values["no-ai"],
115
+ showVersion: parsed.values.version,
116
+ showHelp: parsed.values.help,
117
+ };
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // Score reveal animation
122
+ // ---------------------------------------------------------------------------
123
+
124
+ /**
125
+ * Counts the progress bar up from 0 → score with an ease-out curve,
126
+ * then leaves the cursor on a new line ready for the dashboard box.
127
+ *
128
+ * @param {number} score
129
+ */
130
+ async function animateScoreReveal(score) {
131
+ const frames = 40;
132
+ // Hide cursor during animation to prevent flicker
133
+ process.stdout.write("\x1B[?25l");
134
+
135
+ for (let i = 0; i <= frames; i++) {
136
+ const current = Math.round(easeOut(i / frames) * score);
137
+ const bar = renderProgressBar(current);
138
+ const badge = renderScoreBadge(current);
139
+ process.stdout.write(`\r ${bar} ${badge} `);
140
+ await sleep(16);
141
+ }
142
+
143
+ // Restore cursor
144
+ process.stdout.write("\x1B[?25h\n\n");
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // AI hand-off handlers
149
+ // ---------------------------------------------------------------------------
150
+
151
+ function handOffToClaude(prompt) {
152
+ const reportPath = path.resolve(REPORT_FILE);
153
+ if (!fs.existsSync(reportPath)) {
154
+ p.log.warn("Report file not found — run orm-doctor first.");
155
+ return;
156
+ }
157
+ const safePrompt = prompt.replace(/"/g, '\\"');
158
+ p.log.step(chalk.dim("Launching Claude Code…"));
159
+ try {
160
+ execSync(`claude -p "${safePrompt}"`, { stdio: "inherit", shell: true, cwd: process.cwd() });
161
+ } catch (err) {
162
+ if (err.status === 127 || /not found|is not recognized/i.test(err.message ?? "")) {
163
+ p.log.error(
164
+ chalk.red("The `claude` CLI was not found in your PATH.\n") +
165
+ chalk.dim(" Install it: https://docs.anthropic.com/en/docs/claude-code/getting-started")
166
+ );
167
+ } else {
168
+ p.log.warn(chalk.yellow(`Claude exited with code ${err.status ?? "unknown"}.`));
169
+ }
170
+ }
171
+ }
172
+
173
+ async function copyToClipboard(prompt) {
174
+ try {
175
+ await clipboardy.write(prompt);
176
+ p.log.success(chalk.green("Prompt copied to clipboard!"));
177
+ p.log.info(chalk.dim("Paste it into Cursor, ChatGPT, or any AI assistant."));
178
+ } catch (err) {
179
+ p.log.error(chalk.red(`Clipboard write failed: ${err.message}`));
180
+ }
181
+ }
182
+
183
+ function printPrompt(prompt) {
184
+ console.log();
185
+ console.log(
186
+ boxen(chalk.white(prompt), {
187
+ title: chalk.bold.magenta(" Agent Prompt "),
188
+ titleAlignment: "center",
189
+ padding: { top: 1, bottom: 1, left: 2, right: 2 },
190
+ margin: { top: 0, bottom: 1 },
191
+ borderStyle: "round",
192
+ borderColor: "magenta",
193
+ })
194
+ );
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Multi-phase scan with animated spinner stages
199
+ // ---------------------------------------------------------------------------
200
+
201
+ /**
202
+ * Runs all scans across distinct spinner phases so the user sees progress.
203
+ * Returns the full scan result plus dead-code stats.
204
+ *
205
+ * @param {object} opts
206
+ * @param {string} opts.projectPath
207
+ * @param {string} opts.schemaPath
208
+ * @returns {Promise<{ issues, filteredOut, totalPenalty, score }>}
209
+ */
210
+ async function runPhasedScans({ projectPath, schemaPath }) {
211
+ const spinner = p.spinner();
212
+
213
+ // ── Phase 1: Discover ────────────────────────────────────────────────────
214
+ spinner.start(chalk.dim("Discovering source files…"));
215
+ await sleep(350);
216
+
217
+ const allFiles = collectTypeScriptFiles(projectPath);
218
+ const deadFiles = allFiles.filter(isDeadCode);
219
+ const liveFiles = allFiles.filter((f) => !isDeadCode(f));
220
+
221
+ spinner.message(
222
+ chalk.dim(`Found `) +
223
+ chalk.white(`${allFiles.length}`) +
224
+ chalk.dim(` TypeScript files — tracing AST patterns…`)
225
+ );
226
+ await sleep(500);
227
+
228
+ // ── Phase 2: N+1 scan ────────────────────────────────────────────────────
229
+ spinner.message(chalk.dim("Scanning for N+1 query patterns…"));
230
+ await sleep(300);
231
+
232
+ const rawNPlusOne = await scanForNPlusOne(projectPath);
233
+
234
+ spinner.message(chalk.dim("Analysing query call sites…"));
235
+ await sleep(350);
236
+
237
+ // ── Phase 3: Schema scan ─────────────────────────────────────────────────
238
+ spinner.message(chalk.dim("Parsing Prisma schema…"));
239
+ await sleep(300);
240
+
241
+ const rawMissingIdx = await scanForMissingIndexes(schemaPath ?? projectPath);
242
+
243
+ spinner.message(chalk.dim("Checking index coverage on foreign keys…"));
244
+ await sleep(350);
245
+
246
+ // ── Phase 3b: Seed file scan ──────────────────────────────────────────────
247
+ spinner.message(chalk.dim("Analysing Prisma seed files…"));
248
+ await sleep(300);
249
+
250
+ const rawSeedIssues = await scanSeedFiles(projectPath);
251
+
252
+ spinner.message(chalk.dim("Checking seed safety patterns…"));
253
+ await sleep(300);
254
+
255
+ // ── Phase 3c: Advanced query analysis ─────────────────────────────────────
256
+ spinner.message(chalk.dim("Hunting raw-SQL injection & mass-mutation calls…"));
257
+ await sleep(300);
258
+ spinner.message(chalk.dim("Checking pagination, transactions & client singleton…"));
259
+ await sleep(300);
260
+
261
+ const rawAdvanced = await runAdvancedScans(projectPath, schemaPath ?? projectPath);
262
+
263
+ // ── Phase 4: Dead code filter ─────────────────────────────────────────────
264
+ spinner.message(
265
+ chalk.dim("Filtering dead code") +
266
+ (deadFiles.length > 0
267
+ ? chalk.dim(` — ignoring ${deadFiles.length} test/mock file${deadFiles.length !== 1 ? "s" : ""}…`)
268
+ : chalk.dim("…"))
269
+ );
270
+ await sleep(400);
271
+
272
+ const allRaw = [...rawNPlusOne, ...rawMissingIdx, ...rawSeedIssues, ...rawAdvanced];
273
+ const issues = allRaw.filter((issue) => !isDeadCode(issue.file));
274
+ const filteredOut = allRaw.length - issues.length;
275
+
276
+ // ── Phase 5: Score ────────────────────────────────────────────────────────
277
+ spinner.message(chalk.dim("Computing health score…"));
278
+ await sleep(400);
279
+
280
+ const totalPenalty = issues.reduce((s, i) => s + i.penalty, 0);
281
+ const score = Math.max(0, 100 - totalPenalty);
282
+ const stack = detectStack(projectPath, schemaPath ?? projectPath);
283
+
284
+ // Persist report
285
+ try {
286
+ fs.writeFileSync(
287
+ REPORT_FILE,
288
+ JSON.stringify(
289
+ {
290
+ generatedAt: new Date().toISOString(),
291
+ projectPath: path.resolve(projectPath),
292
+ stack,
293
+ score,
294
+ totalPenalty,
295
+ issueCount: issues.length,
296
+ filteredOut,
297
+ liveFilesScanned: liveFiles.length,
298
+ deadFilesIgnored: deadFiles.length,
299
+ issues,
300
+ },
301
+ null,
302
+ 2
303
+ ),
304
+ "utf-8"
305
+ );
306
+ } catch { /* non-fatal */ }
307
+
308
+ const doneMsg = issues.length === 0
309
+ ? chalk.green("Done — no issues found.")
310
+ : chalk.yellow(`Done — ${issues.length} issue${issues.length !== 1 ? "s" : ""} found.`);
311
+
312
+ spinner.stop(doneMsg);
313
+
314
+ if (deadFiles.length > 0) {
315
+ p.log.info(
316
+ chalk.dim(`Ignored `) +
317
+ chalk.white(deadFiles.length) +
318
+ chalk.dim(` dead-code file${deadFiles.length !== 1 ? "s" : ""} (tests / mocks / fixtures)`)
319
+ );
320
+ }
321
+
322
+ return { issues, filteredOut, totalPenalty, score, stack };
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // Agent hand-off menu
327
+ // ---------------------------------------------------------------------------
328
+
329
+ async function showHandOffMenu(issues, score) {
330
+ if (issues.length === 0) {
331
+ p.log.success(chalk.green("Nothing to hand off — codebase looks healthy!"));
332
+ return;
333
+ }
334
+
335
+ const reportPath = path.resolve(REPORT_FILE);
336
+ const agentPrompt = buildAgentPrompt(issues, reportPath);
337
+
338
+ console.log();
339
+
340
+ const choice = await p.select({
341
+ message: chalk.bold("What do you want to do with these issues?"),
342
+ options: [
343
+ {
344
+ value: "claude",
345
+ label: chalk.cyan.bold("Send to Claude Code"),
346
+ hint: "runs `claude -p \"...\"` right here in your shell — it reads the report and fixes the files",
347
+ },
348
+ {
349
+ value: "clipboard",
350
+ label: chalk.magenta.bold("Copy prompt to clipboard"),
351
+ hint: "paste into Cursor, ChatGPT, Claude.ai, or any AI assistant",
352
+ },
353
+ {
354
+ value: "print",
355
+ label: chalk.yellow.bold("Print prompt in terminal"),
356
+ hint: "show the full agent prompt so you can read or copy it manually",
357
+ },
358
+ {
359
+ value: "skip",
360
+ label: chalk.dim("Skip"),
361
+ hint: "exit — report is saved to " + chalk.white(".orm-doctor-report.json"),
362
+ },
363
+ ],
364
+ });
365
+
366
+ if (p.isCancel(choice)) {
367
+ p.cancel("Cancelled.");
368
+ process.exit(0);
369
+ }
370
+
371
+ console.log();
372
+
373
+ switch (choice) {
374
+ case "claude":
375
+ handOffToClaude(agentPrompt);
376
+ break;
377
+ case "clipboard":
378
+ await copyToClipboard(agentPrompt);
379
+ break;
380
+ case "print":
381
+ printPrompt(agentPrompt);
382
+ p.log.info(chalk.dim("Report also saved to: ") + chalk.cyan(reportPath));
383
+ break;
384
+ case "skip":
385
+ p.log.info(chalk.dim("Report saved to: ") + chalk.cyan(reportPath));
386
+ break;
387
+ }
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // Main
392
+ // ---------------------------------------------------------------------------
393
+
394
+ async function main() {
395
+ const args = parseCLIArgs();
396
+
397
+ if (args.showVersion) { console.log(`orm-doctor v${VERSION}`); process.exit(0); }
398
+ if (args.showHelp) { console.log(HELP_TEXT); process.exit(0); }
399
+
400
+ const resolvedProject = path.resolve(args.projectPath);
401
+ if (!fs.existsSync(resolvedProject)) {
402
+ console.error(chalk.red(`Error: path does not exist — ${resolvedProject}`));
403
+ process.exit(1);
404
+ }
405
+
406
+ // ── CI / JSON mode ───────────────────────────────────────────────────────
407
+ if (args.jsonMode) {
408
+ const schema = args.schemaPath ?? args.projectPath;
409
+ const [allNP1, allIdx, allSeed, allAdvanced] = await Promise.all([
410
+ scanForNPlusOne(args.projectPath),
411
+ scanForMissingIndexes(schema),
412
+ scanSeedFiles(args.projectPath),
413
+ runAdvancedScans(args.projectPath, schema),
414
+ ]);
415
+ const issues = [...allNP1, ...allIdx, ...allSeed, ...allAdvanced].filter((i) => !isDeadCode(i.file));
416
+ const total = issues.reduce((s, i) => s + i.penalty, 0);
417
+ const score = Math.max(0, 100 - total);
418
+ const stack = detectStack(args.projectPath, schema);
419
+ console.log(JSON.stringify({
420
+ generatedAt: new Date().toISOString(),
421
+ projectPath: resolvedProject,
422
+ stack, score, totalPenalty: total, issueCount: issues.length, issues,
423
+ }, null, 2));
424
+ process.exit(issues.some((i) => i.severity === "critical") ? 1 : 0);
425
+ }
426
+
427
+ // ── Interactive mode ─────────────────────────────────────────────────────
428
+ console.log();
429
+ p.intro(
430
+ chalk.bgMagenta.white.bold(" orm-doctor ") +
431
+ chalk.dim(` v${VERSION} · ORM & database static analyser · by `) +
432
+ chalk.magenta("NoctisNova") +
433
+ chalk.dim(" noctisnova.com")
434
+ );
435
+
436
+ if (args.schemaPath) {
437
+ p.log.info(chalk.dim("Schema ") + chalk.white(path.resolve(args.schemaPath)));
438
+ }
439
+
440
+ console.log();
441
+
442
+ // ── Multi-phase scan ─────────────────────────────────────────────────────
443
+ let scanResult;
444
+ try {
445
+ scanResult = await runPhasedScans({
446
+ projectPath: args.projectPath,
447
+ schemaPath: args.schemaPath ?? args.projectPath,
448
+ });
449
+ } catch (err) {
450
+ p.log.error(chalk.red(err.message));
451
+ p.outro(chalk.red("orm-doctor encountered an error."));
452
+ process.exit(1);
453
+ }
454
+
455
+ const { issues, score, totalPenalty, stack } = scanResult;
456
+
457
+ // ── Score reveal animation ────────────────────────────────────────────────
458
+ console.log();
459
+ await animateScoreReveal(score);
460
+
461
+ // ── Detected stack line ───────────────────────────────────────────────────
462
+ const stackLine = renderStackLine(stack);
463
+ if (stackLine) console.log(stackLine);
464
+
465
+ // ── Full dashboard ────────────────────────────────────────────────────────
466
+ console.log(
467
+ renderDashboard({
468
+ score,
469
+ totalPenalty,
470
+ issues,
471
+ projectPath: args.projectPath,
472
+ })
473
+ );
474
+
475
+ // ── Agent hand-off ────────────────────────────────────────────────────────
476
+ if (!args.noAi) {
477
+ await showHandOffMenu(issues, score);
478
+ } else {
479
+ p.log.info(chalk.dim("Report saved to: ") + chalk.cyan(path.resolve(REPORT_FILE)));
480
+ }
481
+
482
+ // ── Outro ──────────────────────────────────────────────────────────────────
483
+ const hasCritical = issues.some((i) => i.severity === "critical");
484
+ console.log();
485
+ p.outro(
486
+ hasCritical
487
+ ? chalk.yellow("Fix the critical issues and run orm-doctor again.")
488
+ : chalk.green("Your ORM usage looks solid. Keep it that way.")
489
+ );
490
+
491
+ process.exit(hasCritical ? 1 : 0);
492
+ }
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // Bootstrap
496
+ // ---------------------------------------------------------------------------
497
+
498
+ main().catch((err) => {
499
+ console.error(chalk.red("\nUnexpected error:"), err.message ?? err);
500
+ if (process.env.DEBUG) console.error(err);
501
+ process.exit(1);
502
+ });
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "orm-doctor",
3
+ "version": "1.0.0",
4
+ "description": "Static analysis CLI for detecting ORM/database bottlenecks in TypeScript and Prisma/Drizzle codebases.",
5
+ "type": "module",
6
+ "bin": {
7
+ "orm-doctor": "./index.js"
8
+ },
9
+ "main": "./index.js",
10
+ "files": [
11
+ "index.js",
12
+ "src/"
13
+ ],
14
+ "scripts": {
15
+ "start": "node index.js",
16
+ "lint": "node --check index.js src/scanner.js src/ui.js"
17
+ },
18
+ "keywords": [
19
+ "orm",
20
+ "prisma",
21
+ "drizzle",
22
+ "static-analysis",
23
+ "ast",
24
+ "performance",
25
+ "n+1",
26
+ "database",
27
+ "cli",
28
+ "typescript"
29
+ ],
30
+ "author": "NoctisNova <hello@noctisnova.com> (https://noctisnova.com)",
31
+ "homepage": "https://noctisnova.com",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/NoctisNovaStudio/orm-doctor"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/NoctisNovaStudio/orm-doctor/issues",
38
+ "email": "hello@noctisnova.com"
39
+ },
40
+ "license": "MIT",
41
+ "dependencies": {
42
+ "@clack/prompts": "^0.9.1",
43
+ "@mrleebo/prisma-ast": "^0.12.0",
44
+ "boxen": "^8.0.1",
45
+ "chalk": "^5.4.1",
46
+ "clipboardy": "^4.0.0",
47
+ "ts-morph": "^24.0.0"
48
+ },
49
+ "engines": {
50
+ "node": ">=18.0.0"
51
+ }
52
+ }