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 +51 -0
- package/index.js +502 -0
- package/package.json +52 -0
- package/src/advanced.js +486 -0
- package/src/scanner.js +675 -0
- package/src/ui.js +606 -0
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
|
+
}
|