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/src/advanced.js
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* advanced.js — orm-doctor
|
|
3
|
+
*
|
|
4
|
+
* The advanced query-analysis engine. These rules catch the database bugs that
|
|
5
|
+
* actually take production systems down — the ones a regex grep can't find
|
|
6
|
+
* because they need real AST structure:
|
|
7
|
+
*
|
|
8
|
+
* • unsafe-raw-query — $queryRawUnsafe / string-built SQL → SQL injection
|
|
9
|
+
* • mass-mutation — updateMany/deleteMany with no where → wipes a table
|
|
10
|
+
* • missing-pagination — findMany() with no take/cursor → unbounded full scan
|
|
11
|
+
* • prisma-singleton — new PrismaClient() with no global guard → pool exhaustion
|
|
12
|
+
* • missing-transaction — multiple dependent writes not wrapped in $transaction
|
|
13
|
+
* • missing-relation-action — schema @relation with no onDelete referential action
|
|
14
|
+
*
|
|
15
|
+
* Plus detectStack() for the report header (ORM, DB provider, model count).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { Project, SyntaxKind } from "ts-morph";
|
|
19
|
+
import { getSchema } from "@mrleebo/prisma-ast";
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { collectTypeScriptFiles, isDeadCode } from "./scanner.js";
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Penalties
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
const PENALTY_UNSAFE_RAW = 20;
|
|
29
|
+
const PENALTY_MASS_MUTATION = 18;
|
|
30
|
+
const PENALTY_MISSING_PAGINATION = 8;
|
|
31
|
+
const PENALTY_PRISMA_SINGLETON = 10;
|
|
32
|
+
const PENALTY_MISSING_TX = 8;
|
|
33
|
+
const PENALTY_RELATION_ACTION = 2;
|
|
34
|
+
|
|
35
|
+
const WRITE_METHODS = "(?:create|createMany|update|updateMany|delete|deleteMany|upsert)";
|
|
36
|
+
const DB_RECEIVER_RE = /\b(prisma|db|tx|trx|client|ctx|repo|repository)\b/i;
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Shared ts-morph project factory
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
function makeProject() {
|
|
43
|
+
return new Project({
|
|
44
|
+
skipAddingFilesFromTsConfig: true,
|
|
45
|
+
skipFileDependencyResolution: true,
|
|
46
|
+
compilerOptions: { allowJs: true, noEmit: true },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function trimSnippet(text, max = 120) {
|
|
51
|
+
const first = text.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? text.trim();
|
|
52
|
+
return first.length > max ? first.slice(0, max - 3) + "..." : first;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function liveFiles(projectPath) {
|
|
56
|
+
return collectTypeScriptFiles(path.resolve(projectPath)).filter((f) => !isDeadCode(f));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function propName(callExpr) {
|
|
60
|
+
const pae = callExpr.getExpressionIfKind(SyntaxKind.PropertyAccessExpression);
|
|
61
|
+
return pae ? pae.getName() : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function objectHasProperty(objLit, name) {
|
|
65
|
+
return objLit.getProperties().some((pr) => {
|
|
66
|
+
const nameNode = pr.getChildAtIndexIfKind?.(0, SyntaxKind.Identifier);
|
|
67
|
+
if (nameNode && nameNode.getText() === name) return true;
|
|
68
|
+
// PropertyAssignment / ShorthandPropertyAssignment
|
|
69
|
+
return typeof pr.getName === "function" && pr.getName() === name;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Rule: unsafe raw queries (SQL injection)
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
const UNSAFE_RAW_METHODS = new Set(["$queryRawUnsafe", "$executeRawUnsafe"]);
|
|
78
|
+
const RAW_FUNCTIONAL_METHODS = new Set(["$queryRaw", "$executeRaw"]);
|
|
79
|
+
|
|
80
|
+
/** Is the arg node a dynamic (non-constant) value? */
|
|
81
|
+
function isDynamicArg(arg) {
|
|
82
|
+
if (!arg) return false;
|
|
83
|
+
const k = arg.getKind();
|
|
84
|
+
if (k === SyntaxKind.StringLiteral || k === SyntaxKind.NoSubstitutionTemplateLiteral) return false;
|
|
85
|
+
if (k === SyntaxKind.TemplateExpression) return true; // has ${...}
|
|
86
|
+
if (k === SyntaxKind.BinaryExpression) return true; // "a" + x
|
|
87
|
+
if (k === SyntaxKind.Identifier) return true; // a variable
|
|
88
|
+
if (k === SyntaxKind.CallExpression) return true; // buildQuery()
|
|
89
|
+
if (k === SyntaxKind.PropertyAccessExpression) return true; // obj.sql
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function scanUnsafeRawQueries(projectPath) {
|
|
94
|
+
const issues = [];
|
|
95
|
+
const resolved = path.resolve(projectPath);
|
|
96
|
+
const files = liveFiles(resolved);
|
|
97
|
+
const project = makeProject();
|
|
98
|
+
|
|
99
|
+
for (const filePath of files) {
|
|
100
|
+
let sf; try { sf = project.addSourceFileAtPath(filePath); } catch { continue; }
|
|
101
|
+
const rel = path.relative(resolved, filePath).replace(/\\/g, "/");
|
|
102
|
+
|
|
103
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
104
|
+
const name = propName(call);
|
|
105
|
+
if (!name) continue;
|
|
106
|
+
|
|
107
|
+
const isUnsafeName = UNSAFE_RAW_METHODS.has(name);
|
|
108
|
+
const isFunctional = RAW_FUNCTIONAL_METHODS.has(name); // $queryRaw used WITH parens (not tagged)
|
|
109
|
+
|
|
110
|
+
if (!isUnsafeName && !isFunctional) continue;
|
|
111
|
+
|
|
112
|
+
const arg = call.getArguments()[0];
|
|
113
|
+
// $queryRawUnsafe is dangerous whenever the SQL string is dynamic.
|
|
114
|
+
// $queryRaw/$executeRaw used as a *function* (parens) bypasses parameterisation;
|
|
115
|
+
// their SAFE form is a tagged template, which is a TaggedTemplateExpression (not a CallExpression),
|
|
116
|
+
// so any CallExpression hit here is already the unsafe functional form.
|
|
117
|
+
if (!isDynamicArg(arg)) continue;
|
|
118
|
+
|
|
119
|
+
const line = sf.getLineAndColumnAtPos(call.getStart()).line;
|
|
120
|
+
issues.push({
|
|
121
|
+
type: "Unsafe Raw Query",
|
|
122
|
+
rule: "unsafe-raw-query",
|
|
123
|
+
severity: "critical",
|
|
124
|
+
file: rel,
|
|
125
|
+
line,
|
|
126
|
+
snippet: trimSnippet(call.getText()),
|
|
127
|
+
message:
|
|
128
|
+
`\`${name}\` is called with a dynamically-built SQL string — this is a SQL injection hole. ` +
|
|
129
|
+
"Any user input in that string can run arbitrary SQL. Use a tagged-template `prisma.$queryRaw\\`...\\`` " +
|
|
130
|
+
"with ${} placeholders (auto-parameterised), or pass values as separate parameters to the Unsafe variant.",
|
|
131
|
+
docs: "https://noctisnova.com/docs/orm/raw-query-safety",
|
|
132
|
+
penalty: PENALTY_UNSAFE_RAW,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
project.removeSourceFile(sf);
|
|
136
|
+
}
|
|
137
|
+
return issues;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Rule: mass mutation (updateMany / deleteMany with no where)
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
export async function scanMassMutations(projectPath) {
|
|
145
|
+
const issues = [];
|
|
146
|
+
const resolved = path.resolve(projectPath);
|
|
147
|
+
const files = liveFiles(resolved);
|
|
148
|
+
const project = makeProject();
|
|
149
|
+
|
|
150
|
+
for (const filePath of files) {
|
|
151
|
+
let sf; try { sf = project.addSourceFileAtPath(filePath); } catch { continue; }
|
|
152
|
+
const rel = path.relative(resolved, filePath).replace(/\\/g, "/");
|
|
153
|
+
|
|
154
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
155
|
+
const name = propName(call);
|
|
156
|
+
if (name !== "deleteMany" && name !== "updateMany") continue;
|
|
157
|
+
|
|
158
|
+
const receiver = call.getExpressionIfKind(SyntaxKind.PropertyAccessExpression)?.getExpression()?.getText() ?? "";
|
|
159
|
+
if (!DB_RECEIVER_RE.test(receiver)) continue;
|
|
160
|
+
|
|
161
|
+
const args = call.getArguments();
|
|
162
|
+
if (args.length > 0) {
|
|
163
|
+
// If the arg isn't an inspectable object literal (e.g. a variable), we
|
|
164
|
+
// can't prove there's no `where` — skip to avoid a false positive.
|
|
165
|
+
if (args[0].getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
|
|
166
|
+
if (objectHasProperty(args[0], "where")) continue;
|
|
167
|
+
}
|
|
168
|
+
// Reaching here: no args, or an object literal with no `where` → whole-table mutation.
|
|
169
|
+
|
|
170
|
+
const line = sf.getLineAndColumnAtPos(call.getStart()).line;
|
|
171
|
+
issues.push({
|
|
172
|
+
type: "Mass Mutation",
|
|
173
|
+
rule: "mass-mutation",
|
|
174
|
+
severity: "critical",
|
|
175
|
+
file: rel,
|
|
176
|
+
line,
|
|
177
|
+
snippet: trimSnippet(call.getText()),
|
|
178
|
+
message:
|
|
179
|
+
`\`${name}\` has no \`where\` clause — it will ${name === "deleteMany" ? "DELETE every row" : "UPDATE every row"} in the table. ` +
|
|
180
|
+
"A single accidental call wipes or rewrites all your data. Always scope mass mutations with a `where` filter " +
|
|
181
|
+
"(use `where: {}` explicitly only if you truly mean the whole table, and guard it).",
|
|
182
|
+
docs: "https://noctisnova.com/docs/orm/mass-mutations",
|
|
183
|
+
penalty: PENALTY_MASS_MUTATION,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
project.removeSourceFile(sf);
|
|
187
|
+
}
|
|
188
|
+
return issues;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Rule: missing pagination (findMany with no take / cursor)
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
export async function scanMissingPagination(projectPath) {
|
|
196
|
+
const issues = [];
|
|
197
|
+
const resolved = path.resolve(projectPath);
|
|
198
|
+
const files = liveFiles(resolved);
|
|
199
|
+
const project = makeProject();
|
|
200
|
+
|
|
201
|
+
for (const filePath of files) {
|
|
202
|
+
let sf; try { sf = project.addSourceFileAtPath(filePath); } catch { continue; }
|
|
203
|
+
const rel = path.relative(resolved, filePath).replace(/\\/g, "/");
|
|
204
|
+
|
|
205
|
+
for (const call of sf.getDescendantsOfKind(SyntaxKind.CallExpression)) {
|
|
206
|
+
if (propName(call) !== "findMany") continue;
|
|
207
|
+
|
|
208
|
+
const receiver = call.getExpressionIfKind(SyntaxKind.PropertyAccessExpression)?.getExpression()?.getText() ?? "";
|
|
209
|
+
if (!DB_RECEIVER_RE.test(receiver)) continue;
|
|
210
|
+
|
|
211
|
+
const args = call.getArguments();
|
|
212
|
+
if (args.length > 0) {
|
|
213
|
+
// Non-object arg (a variable holding the query) — can't prove it's unbounded, skip.
|
|
214
|
+
if (args[0].getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
|
|
215
|
+
if (objectHasProperty(args[0], "take") || objectHasProperty(args[0], "cursor")) continue;
|
|
216
|
+
}
|
|
217
|
+
// Reaching here: findMany() with no args, or an object literal with no take/cursor.
|
|
218
|
+
|
|
219
|
+
const line = sf.getLineAndColumnAtPos(call.getStart()).line;
|
|
220
|
+
issues.push({
|
|
221
|
+
type: "Missing Pagination",
|
|
222
|
+
rule: "missing-pagination",
|
|
223
|
+
severity: "warning",
|
|
224
|
+
file: rel,
|
|
225
|
+
line,
|
|
226
|
+
snippet: trimSnippet(call.getText()),
|
|
227
|
+
message:
|
|
228
|
+
"`findMany()` has no `take` or `cursor` — it loads the ENTIRE table into memory. " +
|
|
229
|
+
"That's fine with 10 rows and fatal with 1,000,000: the query slows linearly and can OOM the server. " +
|
|
230
|
+
"Add `take` (and `cursor`/`skip`) to paginate.",
|
|
231
|
+
docs: "https://noctisnova.com/docs/orm/pagination",
|
|
232
|
+
penalty: PENALTY_MISSING_PAGINATION,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
project.removeSourceFile(sf);
|
|
236
|
+
}
|
|
237
|
+
return issues;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// Rule: PrismaClient singleton (pool exhaustion in dev/serverless)
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
const SINGLETON_GUARD_RE = /global(This)?\s*\.\s*\w*prisma|globalFor\w*|(?:const|let|var)\s+global\w*\s*=\s*globalThis/i;
|
|
245
|
+
|
|
246
|
+
export async function scanPrismaSingleton(projectPath) {
|
|
247
|
+
const issues = [];
|
|
248
|
+
const resolved = path.resolve(projectPath);
|
|
249
|
+
const files = liveFiles(resolved);
|
|
250
|
+
|
|
251
|
+
const instantiating = [];
|
|
252
|
+
for (const filePath of files) {
|
|
253
|
+
let src; try { src = fs.readFileSync(filePath, "utf-8"); } catch { continue; }
|
|
254
|
+
if (!/new\s+PrismaClient\s*\(/.test(src)) continue;
|
|
255
|
+
instantiating.push({ filePath, src });
|
|
256
|
+
}
|
|
257
|
+
if (instantiating.length === 0) return issues;
|
|
258
|
+
|
|
259
|
+
const multiple = instantiating.length > 1;
|
|
260
|
+
|
|
261
|
+
for (const { filePath, src } of instantiating) {
|
|
262
|
+
const guarded = SINGLETON_GUARD_RE.test(src);
|
|
263
|
+
if (guarded && !multiple) continue; // properly guarded single instance — ideal
|
|
264
|
+
|
|
265
|
+
const rel = path.relative(resolved, filePath).replace(/\\/g, "/");
|
|
266
|
+
const lines = src.split("\n");
|
|
267
|
+
const line = Math.max(1, lines.findIndex((l) => /new\s+PrismaClient\s*\(/.test(l)) + 1);
|
|
268
|
+
|
|
269
|
+
let message;
|
|
270
|
+
if (multiple && !guarded) {
|
|
271
|
+
message =
|
|
272
|
+
`\`new PrismaClient()\` here — and this project instantiates it in ${instantiating.length} files. ` +
|
|
273
|
+
"Each instance opens its own connection pool; multiple pools exhaust the database's connection limit. " +
|
|
274
|
+
"Export ONE shared client from `lib/prisma.ts` and import it everywhere.";
|
|
275
|
+
} else if (multiple) {
|
|
276
|
+
message =
|
|
277
|
+
`This project creates \`new PrismaClient()\` in ${instantiating.length} files. ` +
|
|
278
|
+
"Collapse them into a single shared client exported from `lib/prisma.ts`.";
|
|
279
|
+
} else {
|
|
280
|
+
message =
|
|
281
|
+
"`new PrismaClient()` is created without a `globalThis` guard. In Next.js dev (hot reload) and " +
|
|
282
|
+
"serverless, this spawns a new connection pool on every reload/invocation and exhausts the database. " +
|
|
283
|
+
"Wrap it: `const prisma = globalThis.prisma ?? new PrismaClient(); if (process.env.NODE_ENV !== 'production') globalThis.prisma = prisma`.";
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
issues.push({
|
|
287
|
+
type: "Prisma Client Singleton",
|
|
288
|
+
rule: "prisma-singleton",
|
|
289
|
+
severity: "warning",
|
|
290
|
+
file: rel,
|
|
291
|
+
line,
|
|
292
|
+
snippet: trimSnippet(lines[line - 1] ?? "new PrismaClient()"),
|
|
293
|
+
message,
|
|
294
|
+
docs: "https://noctisnova.com/docs/orm/prisma-singleton",
|
|
295
|
+
penalty: PENALTY_PRISMA_SINGLETON,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
return issues;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Rule: missing transaction (multiple dependent writes not atomic)
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
export async function scanMissingTransaction(projectPath) {
|
|
306
|
+
const issues = [];
|
|
307
|
+
const resolved = path.resolve(projectPath);
|
|
308
|
+
const files = liveFiles(resolved);
|
|
309
|
+
const project = makeProject();
|
|
310
|
+
|
|
311
|
+
const writeRe = new RegExp(`\\b(?:prisma|tx|trx|db|client)\\s*\\.\\s*\\w+\\s*\\.\\s*${WRITE_METHODS}\\s*\\(`, "g");
|
|
312
|
+
|
|
313
|
+
for (const filePath of files) {
|
|
314
|
+
let sf; try { sf = project.addSourceFileAtPath(filePath); } catch { continue; }
|
|
315
|
+
const rel = path.relative(resolved, filePath).replace(/\\/g, "/");
|
|
316
|
+
|
|
317
|
+
const fnNodes = [
|
|
318
|
+
...sf.getFunctions(),
|
|
319
|
+
...sf.getDescendantsOfKind(SyntaxKind.ArrowFunction),
|
|
320
|
+
...sf.getDescendantsOfKind(SyntaxKind.MethodDeclaration),
|
|
321
|
+
...sf.getDescendantsOfKind(SyntaxKind.FunctionExpression),
|
|
322
|
+
];
|
|
323
|
+
|
|
324
|
+
const seenLines = new Set();
|
|
325
|
+
|
|
326
|
+
for (const fn of fnNodes) {
|
|
327
|
+
const body = fn.getBody?.();
|
|
328
|
+
if (!body) continue;
|
|
329
|
+
const text = body.getText();
|
|
330
|
+
|
|
331
|
+
// Already transactional — skip
|
|
332
|
+
if (/\$transaction\s*[([]/.test(text)) continue;
|
|
333
|
+
// Function receives a tx param (it's a callback inside a transaction) — skip
|
|
334
|
+
const params = fn.getParameters?.() ?? [];
|
|
335
|
+
if (params.some((p_) => /^(tx|trx|transaction)$/i.test(p_.getName()))) continue;
|
|
336
|
+
|
|
337
|
+
const writes = [...text.matchAll(writeRe)];
|
|
338
|
+
if (writes.length < 2) continue;
|
|
339
|
+
|
|
340
|
+
const line = sf.getLineAndColumnAtPos(fn.getStart()).line;
|
|
341
|
+
if (seenLines.has(line)) continue;
|
|
342
|
+
seenLines.add(line);
|
|
343
|
+
|
|
344
|
+
issues.push({
|
|
345
|
+
type: "Missing Transaction",
|
|
346
|
+
rule: "missing-transaction",
|
|
347
|
+
severity: "warning",
|
|
348
|
+
file: rel,
|
|
349
|
+
line,
|
|
350
|
+
snippet: trimSnippet(`${writes.length} writes in one function — not wrapped in $transaction`),
|
|
351
|
+
message:
|
|
352
|
+
`This function performs ${writes.length} write operations that aren't wrapped in a transaction. ` +
|
|
353
|
+
"If the process crashes between writes, your data is left half-updated (e.g. money debited but not credited). " +
|
|
354
|
+
"Wrap dependent writes in `prisma.$transaction([...])` (or the interactive `$transaction(async (tx) => {...})`) so they all commit or all roll back.",
|
|
355
|
+
docs: "https://noctisnova.com/docs/orm/transactions",
|
|
356
|
+
penalty: PENALTY_MISSING_TX,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
project.removeSourceFile(sf);
|
|
360
|
+
}
|
|
361
|
+
return issues;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// Rule: schema relation missing onDelete (referential action)
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
function resolveSchemaFile(schemaPath) {
|
|
369
|
+
let resolved = path.resolve(schemaPath);
|
|
370
|
+
const stat = fs.statSync(resolved, { throwIfNoEntry: false });
|
|
371
|
+
if (!stat) return null;
|
|
372
|
+
if (stat.isDirectory()) {
|
|
373
|
+
const direct = path.join(resolved, "schema.prisma");
|
|
374
|
+
const nested = path.join(resolved, "prisma", "schema.prisma");
|
|
375
|
+
if (fs.existsSync(direct)) resolved = direct;
|
|
376
|
+
else if (fs.existsSync(nested)) resolved = nested;
|
|
377
|
+
else return null;
|
|
378
|
+
}
|
|
379
|
+
return resolved;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export async function scanMissingRelationActions(schemaPath) {
|
|
383
|
+
const issues = [];
|
|
384
|
+
const file = resolveSchemaFile(schemaPath);
|
|
385
|
+
if (!file) return issues;
|
|
386
|
+
|
|
387
|
+
let source; try { source = fs.readFileSync(file, "utf-8"); } catch { return issues; }
|
|
388
|
+
let schema; try { schema = getSchema(source); } catch { return issues; }
|
|
389
|
+
|
|
390
|
+
const rel = path.relative(process.cwd(), file).replace(/\\/g, "/");
|
|
391
|
+
const sourceLines = source.split("\n");
|
|
392
|
+
|
|
393
|
+
function findRelationLine(modelName, fieldName) {
|
|
394
|
+
let inside = false;
|
|
395
|
+
for (let i = 0; i < sourceLines.length; i++) {
|
|
396
|
+
const l = sourceLines[i];
|
|
397
|
+
if (!inside) { if (/^\s*model\s+/.test(l) && l.includes(modelName)) inside = true; continue; }
|
|
398
|
+
if (/^\s*\}/.test(l)) break;
|
|
399
|
+
if (new RegExp(`^\\s+${fieldName}\\s`).test(l)) return i + 1;
|
|
400
|
+
}
|
|
401
|
+
return 0;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
for (const block of schema.list) {
|
|
405
|
+
if (block.type !== "model") continue;
|
|
406
|
+
for (const item of block.properties) {
|
|
407
|
+
if (item.type !== "field") continue;
|
|
408
|
+
const relationAttr = item.attributes?.find((a) => a.type === "attribute" && a.name === "relation");
|
|
409
|
+
if (!relationAttr) continue;
|
|
410
|
+
|
|
411
|
+
// Only the FK-owning side has `fields:` — that's where onDelete belongs
|
|
412
|
+
const args = relationAttr.args ?? [];
|
|
413
|
+
const argText = JSON.stringify(args);
|
|
414
|
+
const ownsForeignKey = /"fields"/.test(argText) || /fields\s*:/.test(argText);
|
|
415
|
+
if (!ownsForeignKey) continue;
|
|
416
|
+
|
|
417
|
+
const hasOnDelete = /onDelete/.test(argText);
|
|
418
|
+
if (hasOnDelete) continue;
|
|
419
|
+
|
|
420
|
+
issues.push({
|
|
421
|
+
type: "Missing Referential Action",
|
|
422
|
+
rule: "missing-relation-action",
|
|
423
|
+
severity: "info",
|
|
424
|
+
file: rel,
|
|
425
|
+
line: findRelationLine(block.name, item.name),
|
|
426
|
+
snippet: `${block.name}.${item.name} @relation(...)`,
|
|
427
|
+
message:
|
|
428
|
+
`Relation \`${block.name}.${item.name}\` has no \`onDelete\` action. The default (\`Restrict\`/\`SetNull\`) ` +
|
|
429
|
+
"is often not what you want — deleting a parent can either fail unexpectedly or silently orphan children. " +
|
|
430
|
+
"Set it explicitly: `onDelete: Cascade` (delete children) or `Restrict` (block) so the behaviour is intentional.",
|
|
431
|
+
docs: "https://noctisnova.com/docs/orm/referential-actions",
|
|
432
|
+
penalty: PENALTY_RELATION_ACTION,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return issues;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// Stack detection (report header)
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
export function detectStack(projectPath, schemaPath) {
|
|
444
|
+
const root = path.resolve(projectPath);
|
|
445
|
+
let pkg = {};
|
|
446
|
+
try { pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf-8")); } catch { /* */ }
|
|
447
|
+
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
448
|
+
|
|
449
|
+
const labels = [];
|
|
450
|
+
const ver = (r) => { const m = String(r ?? "").match(/(\d+)/); return m ? ` ${m[1]}` : ""; };
|
|
451
|
+
|
|
452
|
+
if (deps["@prisma/client"] || deps.prisma) labels.push(`Prisma${ver(deps["@prisma/client"] ?? deps.prisma)}`);
|
|
453
|
+
if (deps["drizzle-orm"]) labels.push("Drizzle");
|
|
454
|
+
if (deps.next) labels.push(`Next.js${ver(deps.next)}`);
|
|
455
|
+
if (deps.typescript) labels.push("TypeScript");
|
|
456
|
+
|
|
457
|
+
// Datasource provider + model count from schema
|
|
458
|
+
let provider = null, modelCount = 0;
|
|
459
|
+
const schemaFile = resolveSchemaFile(schemaPath ?? root);
|
|
460
|
+
if (schemaFile) {
|
|
461
|
+
try {
|
|
462
|
+
const src = fs.readFileSync(schemaFile, "utf-8");
|
|
463
|
+
const m = src.match(/provider\s*=\s*["']([^"']+)["']/);
|
|
464
|
+
if (m) provider = m[1];
|
|
465
|
+
modelCount = (src.match(/^\s*model\s+\w+/gm) ?? []).length;
|
|
466
|
+
} catch { /* */ }
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return { labels, provider, modelCount };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
// Aggregate runner for the advanced rules
|
|
474
|
+
// ---------------------------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
export async function runAdvancedScans(projectPath, schemaPath) {
|
|
477
|
+
const [raw, mass, pagination, singleton, tx, relations] = await Promise.all([
|
|
478
|
+
scanUnsafeRawQueries(projectPath),
|
|
479
|
+
scanMassMutations(projectPath),
|
|
480
|
+
scanMissingPagination(projectPath),
|
|
481
|
+
scanPrismaSingleton(projectPath),
|
|
482
|
+
scanMissingTransaction(projectPath),
|
|
483
|
+
scanMissingRelationActions(schemaPath ?? projectPath),
|
|
484
|
+
]);
|
|
485
|
+
return [...raw, ...mass, ...pagination, ...singleton, ...tx, ...relations];
|
|
486
|
+
}
|