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/src/scanner.js ADDED
@@ -0,0 +1,675 @@
1
+ /**
2
+ * scanner.js
3
+ * AST-based static analyzer for ORM/database bottleneck detection.
4
+ * Uses ts-morph for TypeScript traversal and @mrleebo/prisma-ast for schema parsing.
5
+ */
6
+
7
+ import { Project, SyntaxKind } from "ts-morph";
8
+ import { getSchema } from "@mrleebo/prisma-ast";
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Constants
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const NPLUS1_PENALTY = 15;
17
+ const MISSING_INDEX_PENALTY = 10;
18
+ const SEED_HARDCODED_ID_PENALTY = 8;
19
+ const SEED_NO_TRUNCATE_PENALTY = 8;
20
+ const SEED_NO_DISCONNECT_PENALTY = 3;
21
+ const SEED_LARGE_BATCH_PENALTY = 5;
22
+ const REPORT_FILE = "./.orm-doctor-report.json";
23
+
24
+ /**
25
+ * Regex patterns that identify hardcoded ID values inside seed files.
26
+ * Matches UUID v4, CUID, CUID2, and numeric string IDs assigned to id fields.
27
+ */
28
+ const HARDCODED_ID_PATTERNS = [
29
+ // id: "some-uuid-or-cuid"
30
+ /\bid\s*:\s*["'`][0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}["'`]/i,
31
+ // id: "cjld2cy..." (CUID)
32
+ /\bid\s*:\s*["'`]c[a-z0-9]{20,30}["'`]/,
33
+ // id: "clxxxxxxx..." (CUID2)
34
+ /\bid\s*:\s*["'`][a-z0-9]{24,}["'`]/,
35
+ // id: "1" or id: "123" (numeric string IDs)
36
+ /\bid\s*:\s*["'`]\d+["'`]/,
37
+ // id: 1 or id: 123 (numeric literal IDs — only flag in create/upsert context)
38
+ /\bid\s*:\s*\d+\b/,
39
+ ];
40
+
41
+ /** Seed file name/path patterns — where to look for seed files. */
42
+ const SEED_FILE_PATTERNS = [
43
+ /prisma[/\\]seed\.[tj]sx?$/,
44
+ /prisma[/\\]seed[/\\]index\.[tj]sx?$/,
45
+ /[/\\]seed\.[tj]sx?$/,
46
+ /[/\\]seeds?[/\\].*\.[tj]sx?$/,
47
+ /[/\\]database[/\\]seed[^/\\]*\.[tj]sx?$/,
48
+ ];
49
+
50
+ /** High-volume create calls threshold — flag seeds creating a huge number of records inline. */
51
+ const LARGE_BATCH_THRESHOLD = 50;
52
+
53
+ /**
54
+ * File patterns that indicate dead / non-production code that should not be
55
+ * flagged. Includes test suites, mocks, stubs, fixtures, e2e, and Cypress.
56
+ */
57
+ const DEAD_CODE_PATTERNS = [
58
+ /\.test\.[tj]sx?$/,
59
+ /\.spec\.[tj]sx?$/,
60
+ /\.mock\.[tj]sx?$/,
61
+ /\.stub\.[tj]sx?$/,
62
+ /\.fixture\.[tj]sx?$/,
63
+ /\/__tests__\//,
64
+ /\/__mocks__\//,
65
+ /\/test\//,
66
+ /\/tests\//,
67
+ /\/e2e\//,
68
+ /\/cypress\//,
69
+ /\/playwright\//,
70
+ /\/vitest\//,
71
+ /\/jest\//,
72
+ ];
73
+
74
+ /**
75
+ * Patterns that indicate a database call is being made.
76
+ * Intentionally broad to catch Prisma, Drizzle, raw pg/mysql clients, Knex, etc.
77
+ */
78
+ const DB_CALL_PATTERNS = [
79
+ /prisma\s*\.\s*\w+\s*\.\s*(findMany|findFirst|findUnique|create|update|delete|upsert|count|aggregate|groupBy)/,
80
+ /prisma\s*\.\s*\$queryRaw/,
81
+ /prisma\s*\.\s*\$executeRaw/,
82
+ /db\s*\.\s*(select|insert|update|delete|query|execute|from|where)/,
83
+ /db\s*\.\s*\w+\s*\.\s*(findMany|findFirst|findUnique|create|update|delete)/,
84
+ /\.\s*(query|execute|run)\s*\(/,
85
+ /await\s+\w+\.(query|execute|run)\s*\(/,
86
+ /knex\s*\(/,
87
+ /sequelize\s*\.\s*query/,
88
+ ];
89
+
90
+ /**
91
+ * Node kinds that represent an iteration body we want to flag.
92
+ */
93
+ const ITERATION_CALL_NAMES = new Set(["map", "forEach", "filter", "find", "reduce", "flatMap", "every", "some"]);
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Helper utilities
97
+ // ---------------------------------------------------------------------------
98
+
99
+ /**
100
+ * Returns true when the file path matches a dead-code / test-file pattern.
101
+ * @param {string} filePath
102
+ * @returns {boolean}
103
+ */
104
+ export function isDeadCode(filePath) {
105
+ const normalized = filePath.replace(/\\/g, "/");
106
+ return DEAD_CODE_PATTERNS.some((re) => re.test(normalized));
107
+ }
108
+
109
+ /**
110
+ * Returns true when the given source text contains any known DB call pattern.
111
+ * @param {string} text
112
+ * @returns {boolean}
113
+ */
114
+ function containsDbCall(text) {
115
+ return DB_CALL_PATTERNS.some((re) => re.test(text));
116
+ }
117
+
118
+ /**
119
+ * Trims a multi-line snippet to a single representative line (the first
120
+ * non-empty line), capped at 120 characters for display.
121
+ * @param {string} snippet
122
+ * @returns {string}
123
+ */
124
+ function trimSnippet(snippet) {
125
+ const first = snippet
126
+ .split("\n")
127
+ .map((l) => l.trim())
128
+ .find((l) => l.length > 0) ?? snippet.trim();
129
+ return first.length > 120 ? first.slice(0, 117) + "..." : first;
130
+ }
131
+
132
+ /**
133
+ * Resolves a glob-free base project path to a list of .ts / .tsx source files,
134
+ * skipping node_modules, dist, and .d.ts declaration files.
135
+ * @param {string} projectPath
136
+ * @returns {string[]}
137
+ */
138
+ export function collectTypeScriptFiles(projectPath) {
139
+ const results = [];
140
+
141
+ function walk(dir) {
142
+ let entries;
143
+ try {
144
+ entries = fs.readdirSync(dir, { withFileTypes: true });
145
+ } catch {
146
+ return;
147
+ }
148
+ for (const entry of entries) {
149
+ const full = path.join(dir, entry.name);
150
+ if (entry.isDirectory()) {
151
+ if (["node_modules", "dist", ".next", ".nuxt", "out", ".git"].includes(entry.name)) continue;
152
+ walk(full);
153
+ } else if (entry.isFile() && /\.tsx?$/.test(entry.name) && !entry.name.endsWith(".d.ts")) {
154
+ results.push(full);
155
+ }
156
+ }
157
+ }
158
+
159
+ walk(path.resolve(projectPath));
160
+ return results;
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Rule 1 – N+1 query detector
165
+ // ---------------------------------------------------------------------------
166
+
167
+ /**
168
+ * Scans TypeScript/TSX source files for N+1 query patterns:
169
+ * array iteration callbacks (.map, .forEach, etc.) and for/for-of loops
170
+ * whose body contains an ORM database call.
171
+ *
172
+ * @param {string} projectPath - Root directory to scan.
173
+ * @returns {Promise<import('./types').Issue[]>}
174
+ */
175
+ export async function scanForNPlusOne(projectPath) {
176
+ const issues = [];
177
+ const resolvedPath = path.resolve(projectPath);
178
+ const tsFiles = collectTypeScriptFiles(resolvedPath);
179
+
180
+ if (tsFiles.length === 0) {
181
+ return issues;
182
+ }
183
+
184
+ const project = new Project({
185
+ skipAddingFilesFromTsConfig: true,
186
+ skipFileDependencyResolution: true,
187
+ compilerOptions: {
188
+ allowJs: true,
189
+ resolveJsonModule: false,
190
+ noEmit: true,
191
+ },
192
+ });
193
+
194
+ for (const filePath of tsFiles) {
195
+ let sourceFile;
196
+ try {
197
+ sourceFile = project.addSourceFileAtPath(filePath);
198
+ } catch {
199
+ continue;
200
+ }
201
+
202
+ const relPath = path.relative(resolvedPath, filePath).replace(/\\/g, "/");
203
+
204
+ // -----------------------------------------------------------------------
205
+ // Pattern A: .map() / .forEach() / .filter() / etc. with a DB call inside
206
+ // -----------------------------------------------------------------------
207
+ const callExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression);
208
+
209
+ for (const callExpr of callExpressions) {
210
+ const memberAccess = callExpr.getExpressionIfKind(SyntaxKind.PropertyAccessExpression);
211
+ if (!memberAccess) continue;
212
+
213
+ const methodName = memberAccess.getName();
214
+ if (!ITERATION_CALL_NAMES.has(methodName)) continue;
215
+
216
+ const args = callExpr.getArguments();
217
+ if (args.length === 0) continue;
218
+
219
+ const callbackArg = args[0];
220
+ const callbackText = callbackArg.getText();
221
+
222
+ if (!containsDbCall(callbackText)) continue;
223
+
224
+ const lineNumber = sourceFile.getLineAndColumnAtPos(callExpr.getStart()).line;
225
+ const snippet = trimSnippet(callExpr.getText());
226
+
227
+ issues.push({
228
+ type: "N+1 Query",
229
+ rule: "nplus1",
230
+ severity: "critical",
231
+ file: relPath,
232
+ line: lineNumber,
233
+ snippet,
234
+ message: `Potential N+1: database call found inside \`.${methodName}()\` callback.`,
235
+ docs: "https://www.prisma.io/docs/guides/performance-and-optimization/query-optimization-performance",
236
+ penalty: NPLUS1_PENALTY,
237
+ });
238
+ }
239
+
240
+ // -----------------------------------------------------------------------
241
+ // Pattern B: for...of loops containing a DB call
242
+ // -----------------------------------------------------------------------
243
+ const forOfStatements = sourceFile.getDescendantsOfKind(SyntaxKind.ForOfStatement);
244
+
245
+ for (const forOf of forOfStatements) {
246
+ const bodyText = forOf.getStatement().getText();
247
+ if (!containsDbCall(bodyText)) continue;
248
+
249
+ const lineNumber = sourceFile.getLineAndColumnAtPos(forOf.getStart()).line;
250
+ const snippet = trimSnippet(forOf.getText());
251
+
252
+ issues.push({
253
+ type: "N+1 Query",
254
+ rule: "nplus1",
255
+ severity: "critical",
256
+ file: relPath,
257
+ line: lineNumber,
258
+ snippet,
259
+ message: "Potential N+1: database call found inside a `for...of` loop.",
260
+ docs: "https://www.prisma.io/docs/guides/performance-and-optimization/query-optimization-performance",
261
+ penalty: NPLUS1_PENALTY,
262
+ });
263
+ }
264
+
265
+ // -----------------------------------------------------------------------
266
+ // Pattern C: classic for loops containing a DB call
267
+ // -----------------------------------------------------------------------
268
+ const forStatements = sourceFile.getDescendantsOfKind(SyntaxKind.ForStatement);
269
+
270
+ for (const forStmt of forStatements) {
271
+ const bodyText = forStmt.getStatement().getText();
272
+ if (!containsDbCall(bodyText)) continue;
273
+
274
+ const lineNumber = sourceFile.getLineAndColumnAtPos(forStmt.getStart()).line;
275
+ const snippet = trimSnippet(forStmt.getText());
276
+
277
+ issues.push({
278
+ type: "N+1 Query",
279
+ rule: "nplus1",
280
+ severity: "critical",
281
+ file: relPath,
282
+ line: lineNumber,
283
+ snippet,
284
+ message: "Potential N+1: database call found inside a `for` loop.",
285
+ docs: "https://www.prisma.io/docs/guides/performance-and-optimization/query-optimization-performance",
286
+ penalty: NPLUS1_PENALTY,
287
+ });
288
+ }
289
+
290
+ // -----------------------------------------------------------------------
291
+ // Pattern D: while loops containing a DB call
292
+ // -----------------------------------------------------------------------
293
+ const whileStatements = sourceFile.getDescendantsOfKind(SyntaxKind.WhileStatement);
294
+
295
+ for (const whileStmt of whileStatements) {
296
+ const bodyText = whileStmt.getStatement().getText();
297
+ if (!containsDbCall(bodyText)) continue;
298
+
299
+ const lineNumber = sourceFile.getLineAndColumnAtPos(whileStmt.getStart()).line;
300
+ const snippet = trimSnippet(whileStmt.getText());
301
+
302
+ issues.push({
303
+ type: "N+1 Query",
304
+ rule: "nplus1",
305
+ severity: "critical",
306
+ file: relPath,
307
+ line: lineNumber,
308
+ snippet,
309
+ message: "Potential N+1: database call found inside a `while` loop.",
310
+ docs: "https://www.prisma.io/docs/guides/performance-and-optimization/query-optimization-performance",
311
+ penalty: NPLUS1_PENALTY,
312
+ });
313
+ }
314
+
315
+ project.removeSourceFile(sourceFile);
316
+ }
317
+
318
+ return issues;
319
+ }
320
+
321
+ // ---------------------------------------------------------------------------
322
+ // Rule 2 – Missing index detector (Prisma schema)
323
+ // ---------------------------------------------------------------------------
324
+
325
+ /**
326
+ * Parses a Prisma schema file and flags foreign-key fields (names ending in
327
+ * "Id") that lack a corresponding `@@index` entry in the same model.
328
+ *
329
+ * @param {string} schemaPath - Path to schema.prisma (or its directory).
330
+ * @returns {Promise<import('./types').Issue[]>}
331
+ */
332
+ export async function scanForMissingIndexes(schemaPath) {
333
+ const issues = [];
334
+
335
+ let resolvedSchema = path.resolve(schemaPath);
336
+ const stat = fs.statSync(resolvedSchema, { throwIfNoEntry: false });
337
+
338
+ if (!stat) return issues;
339
+
340
+ if (stat.isDirectory()) {
341
+ const candidate = path.join(resolvedSchema, "schema.prisma");
342
+ if (!fs.existsSync(candidate)) {
343
+ const nested = path.join(resolvedSchema, "prisma", "schema.prisma");
344
+ if (!fs.existsSync(nested)) return issues;
345
+ resolvedSchema = nested;
346
+ } else {
347
+ resolvedSchema = candidate;
348
+ }
349
+ }
350
+
351
+ let schemaSource;
352
+ try {
353
+ schemaSource = fs.readFileSync(resolvedSchema, "utf-8");
354
+ } catch {
355
+ return issues;
356
+ }
357
+
358
+ let schema;
359
+ try {
360
+ schema = getSchema(schemaSource);
361
+ } catch {
362
+ return issues;
363
+ }
364
+
365
+ const relPath = path.relative(process.cwd(), resolvedSchema).replace(/\\/g, "/");
366
+ // Pre-split lines for real line-number lookups
367
+ const sourceLines = schemaSource.split("\n");
368
+
369
+ /**
370
+ * Finds the 1-based line number of a field declaration inside a named model
371
+ * by scanning the source text. Falls back to 0 if not found.
372
+ */
373
+ function findFieldLine(modelName, fieldName) {
374
+ let insideModel = false;
375
+ for (let i = 0; i < sourceLines.length; i++) {
376
+ const line = sourceLines[i];
377
+ if (!insideModel) {
378
+ if (/^\s*model\s+/.test(line) && line.includes(modelName)) insideModel = true;
379
+ continue;
380
+ }
381
+ // Closing brace ends the model block
382
+ if (/^\s*\}/.test(line)) break;
383
+ // Match " fieldName " at the start of a field line
384
+ if (new RegExp(`^\\s+${fieldName}\\s`).test(line)) return i + 1;
385
+ }
386
+ return 0;
387
+ }
388
+
389
+ for (const block of schema.list) {
390
+ if (block.type !== "model") continue;
391
+
392
+ const modelName = block.name;
393
+
394
+ // ── Collect FK fields (names ending in "Id", no @relation attribute) ──
395
+ const foreignKeyFields = [];
396
+ for (const item of block.properties) {
397
+ if (item.type !== "field") continue;
398
+ if (!/Id$/.test(item.name)) continue;
399
+ const isRelationField = item.attributes?.some(
400
+ (a) => a.type === "attribute" && a.name === "relation"
401
+ );
402
+ if (!isRelationField) {
403
+ foreignKeyFields.push({
404
+ name: item.name,
405
+ line: findFieldLine(modelName, item.name),
406
+ });
407
+ }
408
+ }
409
+
410
+ if (foreignKeyFields.length === 0) continue;
411
+
412
+ // ── Collect fields that already have index coverage ────────────────────
413
+ const indexedFields = new Set();
414
+
415
+ for (const item of block.properties) {
416
+ // Block-level @@index → { type:"attribute", kind:"object", name:"index",
417
+ // args:[{ type:"attributeArgument", value:{ type:"array", args:["fieldName",...] } }] }
418
+ if (item.type === "attribute" && item.name === "index") {
419
+ for (const arg of item.args ?? []) {
420
+ const val = arg.value;
421
+ if (val?.type === "array" && Array.isArray(val.args)) {
422
+ for (const f of val.args) {
423
+ if (typeof f === "string") indexedFields.add(f);
424
+ }
425
+ }
426
+ }
427
+ }
428
+
429
+ // Field-level @id / @unique create implicit indexes
430
+ if (item.type === "field") {
431
+ const hasImplicit = item.attributes?.some(
432
+ (a) => a.type === "attribute" && (a.name === "id" || a.name === "unique")
433
+ );
434
+ if (hasImplicit) indexedFields.add(item.name);
435
+ }
436
+ }
437
+
438
+ // ── Flag FK fields with no index coverage ─────────────────────────────
439
+ for (const fk of foreignKeyFields) {
440
+ if (!indexedFields.has(fk.name)) {
441
+ issues.push({
442
+ type: "Missing Index",
443
+ rule: "missing-index",
444
+ severity: "warning",
445
+ file: relPath,
446
+ line: fk.line,
447
+ snippet: `${modelName}.${fk.name}`,
448
+ message: `Foreign key field \`${fk.name}\` in model \`${modelName}\` has no \`@@index\` – unindexed FK causes full table scans on JOINs.`,
449
+ docs: "https://www.prisma.io/docs/concepts/components/prisma-schema/indexes",
450
+ penalty: MISSING_INDEX_PENALTY,
451
+ });
452
+ }
453
+ }
454
+ }
455
+
456
+ return issues;
457
+ }
458
+
459
+ // ---------------------------------------------------------------------------
460
+ // Rule 3 – Seed file analyser
461
+ // ---------------------------------------------------------------------------
462
+
463
+ /**
464
+ * Locates seed files anywhere under projectPath and runs static checks for:
465
+ * - Hardcoded ID literals (UUID / CUID / numeric) assigned to id: fields
466
+ * - Missing deleteMany / truncate before create (re-seed duplicate key risk)
467
+ * - Missing prisma.$disconnect() (hanging CI process)
468
+ * - Unusually large inline create() call count (CI timeout proxy heuristic)
469
+ *
470
+ * @param {string} projectPath
471
+ * @returns {Promise<object[]>}
472
+ */
473
+ export async function scanSeedFiles(projectPath) {
474
+ const issues = [];
475
+ const resolvedPath = path.resolve(projectPath);
476
+
477
+ // ── Locate all seed files ─────────────────────────────────────────────────
478
+ const allFiles = collectTypeScriptFiles(resolvedPath);
479
+
480
+ // Also check JS seed files that collectTypeScriptFiles skips
481
+ const jsSeedFiles = findSeedJsFiles(resolvedPath);
482
+ const seedFiles = [
483
+ ...allFiles.filter((f) => {
484
+ const norm = f.replace(/\\/g, "/");
485
+ return SEED_FILE_PATTERNS.some((re) => re.test(norm));
486
+ }),
487
+ ...jsSeedFiles,
488
+ ];
489
+
490
+ if (seedFiles.length === 0) return issues;
491
+
492
+ const project = new Project({
493
+ skipAddingFilesFromTsConfig: true,
494
+ skipFileDependencyResolution: true,
495
+ compilerOptions: { allowJs: true, noEmit: true },
496
+ });
497
+
498
+ for (const filePath of seedFiles) {
499
+ let source;
500
+ try {
501
+ source = fs.readFileSync(filePath, "utf-8");
502
+ } catch {
503
+ continue;
504
+ }
505
+
506
+ const relPath = path.relative(resolvedPath, filePath).replace(/\\/g, "/");
507
+ const lines = source.split("\n");
508
+
509
+ // ── Check 1: Hardcoded ID literals ───────────────────────────────────────
510
+ for (let i = 0; i < lines.length; i++) {
511
+ const line = lines[i];
512
+ if (HARDCODED_ID_PATTERNS.some((re) => re.test(line))) {
513
+ // Skip if this is a lookup/where clause (id lookup is fine)
514
+ const isWhereLookup = /where\s*:\s*\{/.test(lines[i - 1] ?? "") ||
515
+ /where\s*:\s*\{/.test(line);
516
+ if (isWhereLookup) continue;
517
+
518
+ issues.push({
519
+ type: "Seed Issue",
520
+ rule: "seed-hardcoded-id",
521
+ severity: "warning",
522
+ file: relPath,
523
+ line: i + 1,
524
+ snippet: trimSnippet(line),
525
+ message:
526
+ "Hardcoded ID in seed data — conflicts on re-seed and breaks across environments. " +
527
+ "Let Prisma generate IDs with @default(cuid()) and store references in variables.",
528
+ docs: "https://noctisnova.com/docs/orm/seed-best-practices",
529
+ penalty: SEED_HARDCODED_ID_PENALTY,
530
+ });
531
+ }
532
+ }
533
+
534
+ // ── Check 2: No clear/truncate before creates (duplicate key on re-seed) ─
535
+ const hasCreateCall = /prisma\.\w+\.(create|createMany|upsert)\s*\(/.test(source);
536
+ const hasClearBefore =
537
+ /prisma\.\w+\.(deleteMany|delete)\s*\(/.test(source) ||
538
+ /truncate/i.test(source) ||
539
+ /\$executeRaw.*TRUNCATE/i.test(source);
540
+
541
+ if (hasCreateCall && !hasClearBefore) {
542
+ // Find the first create line for location
543
+ const createLine = lines.findIndex((l) =>
544
+ /prisma\.\w+\.(create|createMany)\s*\(/.test(l)
545
+ );
546
+ issues.push({
547
+ type: "Seed Issue",
548
+ rule: "seed-no-truncate",
549
+ severity: "warning",
550
+ file: relPath,
551
+ line: createLine >= 0 ? createLine + 1 : 1,
552
+ snippet: createLine >= 0 ? trimSnippet(lines[createLine]) : filePath,
553
+ message:
554
+ "Seed file creates records without first clearing existing data. " +
555
+ "Re-running the seed (common in CI) will throw duplicate key errors. " +
556
+ "Add prisma.<model>.deleteMany({}) at the top of your seed in dependency order.",
557
+ docs: "https://noctisnova.com/docs/orm/seed-best-practices",
558
+ penalty: SEED_NO_TRUNCATE_PENALTY,
559
+ });
560
+ }
561
+
562
+ // ── Check 3: Missing $disconnect (hangs CI) ───────────────────────────────
563
+ const hasDisconnect = /\$disconnect\s*\(\s*\)/.test(source);
564
+ const hasFinally = /finally\s*\{/.test(source);
565
+
566
+ if (hasCreateCall && !hasDisconnect) {
567
+ const lastLine = lines.length;
568
+ issues.push({
569
+ type: "Seed Issue",
570
+ rule: "seed-no-disconnect",
571
+ severity: "info",
572
+ file: relPath,
573
+ line: lastLine,
574
+ snippet: "prisma.$disconnect()",
575
+ message:
576
+ "Seed file does not call prisma.$disconnect(). " +
577
+ "The Node.js process will hang in CI until the connection times out. " +
578
+ "Wrap your seed in try/finally and call prisma.$disconnect() in the finally block.",
579
+ docs: "https://noctisnova.com/docs/orm/seed-best-practices",
580
+ penalty: SEED_NO_DISCONNECT_PENALTY,
581
+ });
582
+ }
583
+
584
+ // ── Check 4: Large inline batch (CI timeout heuristic) ───────────────────
585
+ const createMatches = source.match(/prisma\.\w+\.create\s*\(/g) ?? [];
586
+ const createManyMatches = source.match(/prisma\.\w+\.createMany\s*\(/g) ?? [];
587
+ const totalCreates = createMatches.length + createManyMatches.length;
588
+
589
+ if (totalCreates >= LARGE_BATCH_THRESHOLD) {
590
+ issues.push({
591
+ type: "Seed Issue",
592
+ rule: "seed-large-batch",
593
+ severity: "warning",
594
+ file: relPath,
595
+ line: 1,
596
+ snippet: `${totalCreates} create() calls detected`,
597
+ message:
598
+ `Seed file contains ${totalCreates} create() calls — this may exceed CI timeout limits (typically 30s). ` +
599
+ "Use createMany() with a data array for bulk inserts, or split into chunked batches with Promise.all.",
600
+ docs: "https://noctisnova.com/docs/orm/seed-best-practices",
601
+ penalty: SEED_LARGE_BATCH_PENALTY,
602
+ });
603
+ }
604
+ }
605
+
606
+ return issues;
607
+ }
608
+
609
+ /**
610
+ * Finds plain .js seed files that collectTypeScriptFiles skips.
611
+ * @param {string} rootPath
612
+ * @returns {string[]}
613
+ */
614
+ function findSeedJsFiles(rootPath) {
615
+ const results = [];
616
+ function walk(dir) {
617
+ let entries;
618
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
619
+ for (const entry of entries) {
620
+ const full = path.join(dir, entry.name);
621
+ if (entry.isDirectory()) {
622
+ if (["node_modules", "dist", ".next", ".git"].includes(entry.name)) continue;
623
+ walk(full);
624
+ } else if (entry.isFile() && /\.jsx?$/.test(entry.name)) {
625
+ const norm = full.replace(/\\/g, "/");
626
+ if (SEED_FILE_PATTERNS.some((re) => re.test(norm))) results.push(full);
627
+ }
628
+ }
629
+ }
630
+ walk(rootPath);
631
+ return results;
632
+ }
633
+
634
+ // ---------------------------------------------------------------------------
635
+ // Main orchestrator
636
+ // ---------------------------------------------------------------------------
637
+
638
+ /**
639
+ * Runs all scanners against the given project root and writes the JSON report.
640
+ *
641
+ * @param {object} opts
642
+ * @param {string} opts.projectPath - Root directory of the TypeScript project.
643
+ * @param {string} [opts.schemaPath] - Path to schema.prisma or its directory.
644
+ * @returns {Promise<{ issues: import('./types').Issue[], totalPenalty: number, score: number }>}
645
+ */
646
+ export async function runAllScans({ projectPath, schemaPath }) {
647
+ const resolvedSchema = schemaPath ?? projectPath;
648
+
649
+ const [nPlusOneIssues, missingIndexIssues, seedIssues] = await Promise.all([
650
+ scanForNPlusOne(projectPath),
651
+ scanForMissingIndexes(resolvedSchema),
652
+ scanSeedFiles(projectPath),
653
+ ]);
654
+
655
+ const issues = [...nPlusOneIssues, ...missingIndexIssues, ...seedIssues];
656
+ const totalPenalty = issues.reduce((sum, i) => sum + i.penalty, 0);
657
+ const score = Math.max(0, 100 - totalPenalty);
658
+
659
+ const report = {
660
+ generatedAt: new Date().toISOString(),
661
+ projectPath: path.resolve(projectPath),
662
+ score,
663
+ totalPenalty,
664
+ issueCount: issues.length,
665
+ issues,
666
+ };
667
+
668
+ try {
669
+ fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2), "utf-8");
670
+ } catch {
671
+ // Non-fatal
672
+ }
673
+
674
+ return { issues, totalPenalty, score };
675
+ }