prisma-guard-lite 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cullen Meyers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,217 @@
1
+ # Prisma Guard Lite
2
+
3
+ Prisma Guard Lite is a pre-deploy migration risk checker for Prisma projects.
4
+
5
+ It scans Prisma migration SQL for destructive and deployment-sensitive
6
+ operations, then produces clear terminal, Markdown, or JSON results for local
7
+ review and CI.
8
+
9
+ ## Why this exists
10
+
11
+ Prisma migrations can contain operations that deserve deliberate review before
12
+ deployment: dropped columns, dropped tables, unbounded deletes, truncation, and
13
+ column type conversions. These statements can be easy to miss inside generated
14
+ SQL.
15
+
16
+ Prisma Guard Lite makes those risks visible without connecting to your database.
17
+ It works locally, has no runtime dependencies, and can focus on migrations
18
+ introduced by a pull request or deployment.
19
+
20
+ ## Quick start
21
+
22
+ Requires Node.js 18 or newer.
23
+
24
+ Scan the latest migration:
25
+
26
+ ```bash
27
+ npx prisma-guard-lite --latest
28
+ ```
29
+
30
+ Scan migrations changed since the main branch:
31
+
32
+ ```bash
33
+ npx prisma-guard-lite --since main
34
+ ```
35
+
36
+ Check staged migrations and fail when a high-severity risk is found:
37
+
38
+ ```bash
39
+ npx prisma-guard-lite --staged --fail-on high
40
+ ```
41
+
42
+ Pass a project directory to scan somewhere other than the current directory:
43
+
44
+ ```bash
45
+ npx prisma-guard-lite /path/to/project --latest
46
+ ```
47
+
48
+ Unless `--no-write` is provided, the command writes
49
+ `prisma-guard-report.md` in the scanned project root.
50
+
51
+ ## What it checks
52
+
53
+ High severity:
54
+
55
+ - `DROP TABLE`
56
+ - `DROP COLUMN`
57
+ - `TRUNCATE`
58
+ - `DELETE FROM` without a `WHERE` clause
59
+ - risky `ALTER TABLE ... ALTER COLUMN ... TYPE` operations
60
+
61
+ Medium severity:
62
+
63
+ - `CREATE EXTENSION`
64
+ - `DROP EXTENSION`
65
+ - Prisma `Unsupported(...)`
66
+ - Prisma `dbgenerated(...)`
67
+ - soft deletion combined with `@unique`
68
+ - tenant-like fields without a matching `@@index`
69
+
70
+ Noisy best-practice checks are disabled by default. `--include-low` enables
71
+ checks for missing timestamp fields and simple tenant-ownership heuristics.
72
+
73
+ ## Scan modes
74
+
75
+ Only one scan mode may be used at a time.
76
+
77
+ | Command | Migration files scanned |
78
+ | --- | --- |
79
+ | `npx prisma-guard-lite` | All migrations, labeled as a history scan |
80
+ | `npx prisma-guard-lite --latest` | The newest migration folder |
81
+ | `npx prisma-guard-lite --since main` | Migration files changed since the provided Git ref |
82
+ | `npx prisma-guard-lite --staged` | Staged migration files |
83
+ | `npx prisma-guard-lite --changed` | Changed and untracked migration files |
84
+
85
+ Git-aware modes require a Git worktree.
86
+
87
+ ## Options
88
+
89
+ | Flag | Behavior |
90
+ | --- | --- |
91
+ | `--json` | Print structured JSON |
92
+ | `--include-low` | Include noisy low-severity best-practice checks |
93
+ | `--no-write` | Do not write `prisma-guard-report.md` |
94
+ | `--fail-on high` | Exit with code 1 when high findings exist |
95
+ | `--fail-on medium` | Exit with code 1 when high or medium findings exist |
96
+
97
+ ## Example output
98
+
99
+ ```text
100
+ Prisma Guard Lite
101
+
102
+ Scan mode: latest migration
103
+ Files scanned: 1 migration file
104
+ Summary: 5 high, 6 medium, 0 low
105
+
106
+ HIGH (5)
107
+ 1. Migration changes a column type
108
+ prisma/migrations/20260101000000_init/migration.sql:3
109
+ Changing a column type can rewrite or lock a table and may fail when existing values cannot be converted.
110
+ Suggested fix: Test the conversion on production-like data and consider a staged add, backfill, and swap migration.
111
+ ```
112
+
113
+ See the complete [high-risk report](examples/reports/example-high-risk-report.md)
114
+ and [clean report](examples/reports/example-clean-report.md).
115
+
116
+ ## JSON output
117
+
118
+ ```bash
119
+ npx prisma-guard-lite --latest --json --no-write
120
+ ```
121
+
122
+ ```json
123
+ {
124
+ "scanMode": "latest migration",
125
+ "filesScanned": 1,
126
+ "summary": {
127
+ "high": 1,
128
+ "medium": 0,
129
+ "low": 0
130
+ },
131
+ "findings": [
132
+ {
133
+ "severity": "high",
134
+ "file": "prisma/migrations/20260623090000_remove_legacy/migration.sql",
135
+ "line": 4,
136
+ "title": "Migration drops a column",
137
+ "explanation": "Dropping a column permanently removes its stored data and may break older application versions.",
138
+ "suggestedFix": "Stop reading and writing the column first, deploy that change, then remove the column in a later migration."
139
+ }
140
+ ]
141
+ }
142
+ ```
143
+
144
+ ## GitHub Actions
145
+
146
+ This workflow checks migration files changed since `origin/main` and fails the
147
+ job when a high-severity finding exists:
148
+
149
+ ```yaml
150
+ name: Prisma migration guard
151
+
152
+ on:
153
+ pull_request:
154
+
155
+ jobs:
156
+ prisma-guard:
157
+ runs-on: ubuntu-latest
158
+ steps:
159
+ - uses: actions/checkout@v4
160
+ with:
161
+ fetch-depth: 0
162
+ - uses: actions/setup-node@v4
163
+ with:
164
+ node-version: 22
165
+ cache: npm
166
+ - run: npm install
167
+ - run: npx prisma-guard-lite --since origin/main --fail-on high
168
+ ```
169
+
170
+ A copy-ready version is available at
171
+ [examples/github-action.yml](examples/github-action.yml).
172
+
173
+ ## Local development
174
+
175
+ ```bash
176
+ npm install
177
+ npm run build
178
+ node dist/index.js example --latest
179
+ ```
180
+
181
+ During development:
182
+
183
+ ```bash
184
+ npm run dev -- example --latest
185
+ ```
186
+
187
+ ## Local verification
188
+
189
+ Run the release checks:
190
+
191
+ ```bash
192
+ npm run build
193
+ node dist/index.js example --latest
194
+ node dist/index.js example --json
195
+ node dist/index.js example --latest --fail-on high
196
+ ```
197
+
198
+ Expected results for the included example:
199
+
200
+ - the build exits `0`
201
+ - `--latest` exits `0` and reports 5 high, 6 medium, and 0 low findings
202
+ - `--json` exits `0` and returns `scanMode`, `filesScanned`, `summary`, and
203
+ `findings`
204
+ - `--latest --fail-on high` prints the report and exits `1`
205
+
206
+ ## Validation
207
+
208
+ The rules were tested against seven public Prisma projects containing 145
209
+ migrations. The validation led to focused Git-based scan modes and disabling
210
+ noisy schema conventions by default. See [VALIDATION.md](VALIDATION.md).
211
+
212
+ ## Disclaimer
213
+
214
+ This is a heuristic scanner. It does not guarantee database safety.
215
+
216
+ Always review migrations, test against production-like data, maintain backups,
217
+ and prepare a rollback or recovery plan before deployment.
package/VALIDATION.md ADDED
@@ -0,0 +1,53 @@
1
+ # Validation
2
+
3
+ Prisma Guard Lite was tested against seven public Prisma projects containing
4
+ 145 committed migration files.
5
+
6
+ ## What the first validation found
7
+
8
+ The original scanner reviewed every migration in repository history. It found
9
+ real destructive operations, but the whole-history approach produced too much
10
+ noise for pull-request and deployment review. Old, already-applied migrations
11
+ appeared beside newly introduced changes and could dominate the report.
12
+
13
+ ## What changed in v2
14
+
15
+ The checker now supports focused migration scopes:
16
+
17
+ - `--latest` for the newest migration
18
+ - `--since <git-ref>` for branch and pull-request review
19
+ - `--staged` for pre-commit review
20
+ - `--changed` for local working-tree review
21
+
22
+ The default remains a clearly labeled history scan for broad audits.
23
+
24
+ ## Most valuable findings
25
+
26
+ The rules that produced the clearest deployment signals were:
27
+
28
+ - `DROP COLUMN`
29
+ - `DROP TABLE`
30
+ - `TRUNCATE`
31
+ - `DELETE FROM` without `WHERE`
32
+ - risky `ALTER COLUMN TYPE` operations
33
+
34
+ The type-change matcher was also tightened after validation exposed false
35
+ positives involving a column literally named `type`.
36
+
37
+ ## Noise reduction
38
+
39
+ The following best-practice checks are disabled by default:
40
+
41
+ - missing `createdAt`
42
+ - missing `updatedAt`
43
+ - missing `deletedAt`
44
+ - tenant ownership guessed from model names
45
+
46
+ They remain available through `--include-low`, but do not dilute the default
47
+ pre-deploy risk report.
48
+
49
+ ## Current verdict
50
+
51
+ The useful core is a focused Prisma migration risk checker, not a general schema
52
+ style linter. Public validation should now concentrate on whether findings from
53
+ `--since`, `--staged`, and `--changed` are actionable in real pull requests.
package/dist/index.js ADDED
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __importDefault = (this && this.__importDefault) || function (mod) {
4
+ return (mod && mod.__esModule) ? mod : { "default": mod };
5
+ };
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ const node_fs_1 = require("node:fs");
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const report_js_1 = require("./report.js");
10
+ const scanner_js_1 = require("./scanner.js");
11
+ function parseArgs(args) {
12
+ let projectPath;
13
+ let json = false;
14
+ let mode = "history";
15
+ let sinceRef;
16
+ let includeLow = false;
17
+ let writeReport = true;
18
+ let failOn;
19
+ let selectedMode;
20
+ const selectMode = (nextMode, flag) => {
21
+ if (selectedMode) {
22
+ throw new Error(`${flag} cannot be combined with ${selectedMode}.`);
23
+ }
24
+ mode = nextMode;
25
+ selectedMode = flag;
26
+ };
27
+ for (let index = 0; index < args.length; index += 1) {
28
+ const arg = args[index];
29
+ if (arg === "--json") {
30
+ json = true;
31
+ }
32
+ else if (arg === "--latest") {
33
+ selectMode("latest", arg);
34
+ }
35
+ else if (arg === "--staged") {
36
+ selectMode("staged", arg);
37
+ }
38
+ else if (arg === "--changed") {
39
+ selectMode("changed", arg);
40
+ }
41
+ else if (arg === "--since") {
42
+ selectMode("since", arg);
43
+ sinceRef = args[index + 1];
44
+ if (!sinceRef || sinceRef.startsWith("-")) {
45
+ throw new Error("--since requires a Git ref.");
46
+ }
47
+ index += 1;
48
+ }
49
+ else if (arg === "--include-low") {
50
+ includeLow = true;
51
+ }
52
+ else if (arg === "--no-write") {
53
+ writeReport = false;
54
+ }
55
+ else if (arg === "--fail-on") {
56
+ const threshold = args[index + 1];
57
+ if (threshold !== "high" && threshold !== "medium") {
58
+ throw new Error("--fail-on must be either high or medium.");
59
+ }
60
+ failOn = threshold;
61
+ index += 1;
62
+ }
63
+ else if (arg.startsWith("-")) {
64
+ throw new Error(`Unknown option: ${arg}`);
65
+ }
66
+ else if (projectPath) {
67
+ throw new Error("Only one project path may be provided.");
68
+ }
69
+ else {
70
+ projectPath = arg;
71
+ }
72
+ }
73
+ return {
74
+ projectRoot: node_path_1.default.resolve(projectPath ?? process.cwd()),
75
+ json,
76
+ mode,
77
+ sinceRef,
78
+ includeLow,
79
+ writeReport,
80
+ failOn,
81
+ };
82
+ }
83
+ async function main() {
84
+ const options = parseArgs(process.argv.slice(2));
85
+ const stat = await node_fs_1.promises.stat(options.projectRoot).catch(() => null);
86
+ if (!stat?.isDirectory()) {
87
+ throw new Error(`Project directory not found: ${options.projectRoot}`);
88
+ }
89
+ const result = await (0, scanner_js_1.scanProject)(options.projectRoot, {
90
+ mode: options.mode,
91
+ sinceRef: options.sinceRef,
92
+ includeLow: options.includeLow,
93
+ });
94
+ const reportPath = node_path_1.default.join(options.projectRoot, "prisma-guard-report.md");
95
+ if (options.writeReport) {
96
+ await node_fs_1.promises.writeFile(reportPath, (0, report_js_1.formatMarkdownReport)(result), "utf8");
97
+ }
98
+ if (options.json) {
99
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
100
+ }
101
+ else {
102
+ const reportStatus = options.writeReport
103
+ ? `Markdown report: ${reportPath}`
104
+ : "Markdown report: not written (--no-write)";
105
+ process.stdout.write(`${(0, report_js_1.formatTerminalReport)(result)}\n\n${reportStatus}\n`);
106
+ }
107
+ const shouldFail = options.failOn === "high"
108
+ ? result.summary.high > 0
109
+ : options.failOn === "medium"
110
+ ? result.summary.high > 0 || result.summary.medium > 0
111
+ : false;
112
+ if (shouldFail) {
113
+ process.exitCode = 1;
114
+ }
115
+ }
116
+ main().catch((error) => {
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ process.stderr.write(`prisma-guard-lite: ${message}\n`);
119
+ process.exitCode = 1;
120
+ });
package/dist/report.js ADDED
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatTerminalReport = formatTerminalReport;
4
+ exports.formatMarkdownReport = formatMarkdownReport;
5
+ const severityLabels = {
6
+ high: "HIGH",
7
+ medium: "MEDIUM",
8
+ low: "LOW",
9
+ };
10
+ const severities = ["high", "medium", "low"];
11
+ function location(finding) {
12
+ return finding.line === null
13
+ ? finding.file
14
+ : `${finding.file}:${finding.line}`;
15
+ }
16
+ function formatTerminalReport(result) {
17
+ const lines = [
18
+ "Prisma Guard Lite",
19
+ "",
20
+ `Scan mode: ${result.scanMode}`,
21
+ `Files scanned: ${result.filesScanned} migration ${result.filesScanned === 1 ? "file" : "files"}`,
22
+ `Summary: ${result.summary.high} high, ${result.summary.medium} medium, ${result.summary.low} low`,
23
+ ];
24
+ for (const severity of severities) {
25
+ const findings = result.findings.filter((finding) => finding.severity === severity);
26
+ lines.push("", `${severityLabels[severity]} (${findings.length})`);
27
+ if (findings.length === 0) {
28
+ lines.push(" No findings.");
29
+ continue;
30
+ }
31
+ findings.forEach((finding, index) => {
32
+ lines.push(` ${index + 1}. ${finding.title}`, ` ${location(finding)}`, ` ${finding.explanation}`, ` Suggested fix: ${finding.suggestedFix}`);
33
+ });
34
+ }
35
+ return lines.join("\n");
36
+ }
37
+ function formatMarkdownReport(result) {
38
+ const lines = [
39
+ "# Prisma Guard Lite Report",
40
+ "",
41
+ "## Summary",
42
+ "",
43
+ `- **Scan mode:** ${result.scanMode}`,
44
+ `- **Files scanned:** ${result.filesScanned} migration ${result.filesScanned === 1 ? "file" : "files"}`,
45
+ "",
46
+ "| Severity | Count |",
47
+ "| --- | ---: |",
48
+ `| High | ${result.summary.high} |`,
49
+ `| Medium | ${result.summary.medium} |`,
50
+ `| Low | ${result.summary.low} |`,
51
+ ];
52
+ for (const severity of severities) {
53
+ const findings = result.findings.filter((finding) => finding.severity === severity);
54
+ lines.push("", `## ${severityLabels[severity]} Findings`, "");
55
+ if (findings.length === 0) {
56
+ lines.push("No findings.");
57
+ continue;
58
+ }
59
+ findings.forEach((finding, index) => {
60
+ lines.push(`### ${index + 1}. ${finding.title}`, "", `- **Severity:** ${severityLabels[finding.severity]}`, `- **File:** \`${finding.file}\``, `- **Line:** ${finding.line ?? "Not available"}`, `- **Explanation:** ${finding.explanation}`, `- **Suggested fix:** ${finding.suggestedFix}`, "");
61
+ });
62
+ }
63
+ lines.push("## Suggested Next Steps", "", "1. Review all high-severity findings before deployment.", "2. Test risky migrations against a recent production-like backup.", "3. Confirm backups and rollback procedures are ready.", "4. Review medium- and low-severity findings for relevance to your application.", "", "> Prisma Guard Lite is a heuristic pre-deploy scanner, not a guarantee of database safety.", "");
64
+ return lines.join("\n");
65
+ }
@@ -0,0 +1,199 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.scanProject = scanProject;
7
+ const node_fs_1 = require("node:fs");
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const node_util_1 = require("node:util");
10
+ const node_child_process_1 = require("node:child_process");
11
+ const schemaParser_js_1 = require("./schemaParser.js");
12
+ const sqlChecks_js_1 = require("./sqlChecks.js");
13
+ const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
14
+ const severityOrder = {
15
+ high: 0,
16
+ medium: 1,
17
+ low: 2,
18
+ };
19
+ async function findMigrationFiles(directory) {
20
+ try {
21
+ const entries = await node_fs_1.promises.readdir(directory, { withFileTypes: true });
22
+ const files = await Promise.all(entries.map(async (entry) => {
23
+ const fullPath = node_path_1.default.join(directory, entry.name);
24
+ if (entry.isDirectory()) {
25
+ return findMigrationFiles(fullPath);
26
+ }
27
+ return entry.isFile() && entry.name === "migration.sql"
28
+ ? [fullPath]
29
+ : [];
30
+ }));
31
+ return files.flat();
32
+ }
33
+ catch (error) {
34
+ if (error.code === "ENOENT") {
35
+ return [];
36
+ }
37
+ throw error;
38
+ }
39
+ }
40
+ async function findLatestMigrationFile(directory) {
41
+ try {
42
+ const entries = await node_fs_1.promises.readdir(directory, { withFileTypes: true });
43
+ const folders = entries
44
+ .filter((entry) => entry.isDirectory())
45
+ .map((entry) => entry.name)
46
+ .sort()
47
+ .reverse();
48
+ for (const folder of folders) {
49
+ const migrationPath = node_path_1.default.join(directory, folder, "migration.sql");
50
+ const stat = await node_fs_1.promises.stat(migrationPath).catch(() => null);
51
+ if (stat?.isFile()) {
52
+ return [migrationPath];
53
+ }
54
+ }
55
+ return [];
56
+ }
57
+ catch (error) {
58
+ if (error.code === "ENOENT") {
59
+ return [];
60
+ }
61
+ throw error;
62
+ }
63
+ }
64
+ function relativeFile(root, filePath) {
65
+ return node_path_1.default.relative(root, filePath).split(node_path_1.default.sep).join("/");
66
+ }
67
+ async function gitLines(projectRoot, args) {
68
+ try {
69
+ const { stdout } = await execFileAsync("git", ["-C", projectRoot, ...args], {
70
+ encoding: "utf8",
71
+ });
72
+ return stdout
73
+ .split(/\r?\n/)
74
+ .map((line) => line.trim())
75
+ .filter(Boolean);
76
+ }
77
+ catch (error) {
78
+ const stderr = error.stderr?.trim();
79
+ throw new Error(stderr || "This scan mode requires a Git worktree.");
80
+ }
81
+ }
82
+ async function existingMigrationPaths(projectRoot, relativePaths) {
83
+ const uniquePaths = [...new Set(relativePaths)];
84
+ const results = await Promise.all(uniquePaths.map(async (relativePath) => {
85
+ const normalized = relativePath.split(node_path_1.default.sep).join("/");
86
+ if (!/^prisma\/migrations\/.+\/migration\.sql$/.test(normalized)) {
87
+ return null;
88
+ }
89
+ const fullPath = node_path_1.default.join(projectRoot, relativePath);
90
+ const stat = await node_fs_1.promises.stat(fullPath).catch(() => null);
91
+ return stat?.isFile() ? fullPath : null;
92
+ }));
93
+ return results.filter((filePath) => filePath !== null).sort();
94
+ }
95
+ async function selectMigrationFiles(projectRoot, options) {
96
+ const migrationRoot = node_path_1.default.join(projectRoot, "prisma", "migrations");
97
+ if (options.mode === "history") {
98
+ return (await findMigrationFiles(migrationRoot)).sort();
99
+ }
100
+ if (options.mode === "latest") {
101
+ return findLatestMigrationFile(migrationRoot);
102
+ }
103
+ const pathspec = "prisma/migrations/**/migration.sql";
104
+ if (options.mode === "since") {
105
+ if (!options.sinceRef) {
106
+ throw new Error("--since requires a Git ref.");
107
+ }
108
+ const files = await gitLines(projectRoot, [
109
+ "diff",
110
+ "--relative",
111
+ "--name-only",
112
+ "--diff-filter=ACMR",
113
+ options.sinceRef,
114
+ "--",
115
+ pathspec,
116
+ ]);
117
+ return existingMigrationPaths(projectRoot, files);
118
+ }
119
+ if (options.mode === "staged") {
120
+ const files = await gitLines(projectRoot, [
121
+ "diff",
122
+ "--relative",
123
+ "--cached",
124
+ "--name-only",
125
+ "--diff-filter=ACMR",
126
+ "--",
127
+ pathspec,
128
+ ]);
129
+ return existingMigrationPaths(projectRoot, files);
130
+ }
131
+ const tracked = await gitLines(projectRoot, [
132
+ "diff",
133
+ "--relative",
134
+ "--name-only",
135
+ "--diff-filter=ACMR",
136
+ "HEAD",
137
+ "--",
138
+ pathspec,
139
+ ]);
140
+ const untracked = await gitLines(projectRoot, [
141
+ "ls-files",
142
+ "--others",
143
+ "--exclude-standard",
144
+ "--",
145
+ pathspec,
146
+ ]);
147
+ return existingMigrationPaths(projectRoot, [...tracked, ...untracked]);
148
+ }
149
+ function scanModeLabel(options) {
150
+ switch (options.mode) {
151
+ case "latest":
152
+ return "latest migration";
153
+ case "since":
154
+ return `migrations changed since ${options.sinceRef}`;
155
+ case "staged":
156
+ return "staged migrations";
157
+ case "changed":
158
+ return "changed migrations";
159
+ default:
160
+ return "history scan";
161
+ }
162
+ }
163
+ async function scanProject(projectRoot, options) {
164
+ const findings = [];
165
+ const schemaPath = node_path_1.default.join(projectRoot, "prisma", "schema.prisma");
166
+ try {
167
+ const schema = await node_fs_1.promises.readFile(schemaPath, "utf8");
168
+ findings.push(...(0, schemaParser_js_1.checkSchema)(schema, relativeFile(projectRoot, schemaPath), options.includeLow));
169
+ }
170
+ catch (error) {
171
+ if (error.code !== "ENOENT") {
172
+ throw error;
173
+ }
174
+ }
175
+ const migrationFiles = await selectMigrationFiles(projectRoot, options);
176
+ for (const migrationPath of migrationFiles) {
177
+ const sql = await node_fs_1.promises.readFile(migrationPath, "utf8");
178
+ findings.push(...(0, sqlChecks_js_1.checkSql)(sql, relativeFile(projectRoot, migrationPath)));
179
+ }
180
+ findings.sort((a, b) => {
181
+ return (severityOrder[a.severity] - severityOrder[b.severity] ||
182
+ a.file.localeCompare(b.file) ||
183
+ (a.line ?? Number.MAX_SAFE_INTEGER) - (b.line ?? Number.MAX_SAFE_INTEGER));
184
+ });
185
+ const summary = {
186
+ high: 0,
187
+ medium: 0,
188
+ low: 0,
189
+ };
190
+ for (const finding of findings) {
191
+ summary[finding.severity] += 1;
192
+ }
193
+ return {
194
+ scanMode: scanModeLabel(options),
195
+ filesScanned: migrationFiles.length,
196
+ summary,
197
+ findings,
198
+ };
199
+ }
@@ -0,0 +1,163 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.checkSchema = checkSchema;
4
+ const tenantFields = [
5
+ "tenantId",
6
+ "orgId",
7
+ "organizationId",
8
+ "workspaceId",
9
+ "teamId",
10
+ ];
11
+ const tenantOwnedModelNames = new Set([
12
+ "project",
13
+ "task",
14
+ "customer",
15
+ "invoice",
16
+ "order",
17
+ "subscription",
18
+ "member",
19
+ "file",
20
+ "document",
21
+ "message",
22
+ "ticket",
23
+ "product",
24
+ ]);
25
+ function lineNumberAt(text, index) {
26
+ return text.slice(0, index).split("\n").length;
27
+ }
28
+ function parseModels(schema) {
29
+ const models = [];
30
+ const modelPattern = /\bmodel\s+(\w+)\s*\{([\s\S]*?)\}/g;
31
+ for (const match of schema.matchAll(modelPattern)) {
32
+ const name = match[1];
33
+ const body = match[2];
34
+ const modelStart = match.index ?? 0;
35
+ const bodyStart = modelStart + match[0].indexOf(body);
36
+ const fields = [];
37
+ const indexes = [];
38
+ body.split("\n").forEach((rawLine, offset) => {
39
+ const source = rawLine.trim();
40
+ const line = lineNumberAt(schema, bodyStart) + offset;
41
+ if (!source || source.startsWith("//")) {
42
+ return;
43
+ }
44
+ if (source.startsWith("@@index")) {
45
+ indexes.push(source);
46
+ return;
47
+ }
48
+ if (source.startsWith("@@") || source.startsWith("///")) {
49
+ return;
50
+ }
51
+ const fieldMatch = source.match(/^(\w+)\s+\S+/);
52
+ if (fieldMatch) {
53
+ fields.push({ name: fieldMatch[1], line, source });
54
+ }
55
+ });
56
+ models.push({
57
+ name,
58
+ line: lineNumberAt(schema, modelStart),
59
+ fields,
60
+ indexes,
61
+ });
62
+ }
63
+ return models;
64
+ }
65
+ function findSchemaPattern(schema, file, pattern, title, explanation, suggestedFix) {
66
+ const findings = [];
67
+ for (const match of schema.matchAll(pattern)) {
68
+ findings.push({
69
+ severity: "medium",
70
+ file,
71
+ line: lineNumberAt(schema, match.index ?? 0),
72
+ title,
73
+ explanation,
74
+ suggestedFix,
75
+ });
76
+ }
77
+ return findings;
78
+ }
79
+ function checkSchema(schema, file, includeLow) {
80
+ const findings = [
81
+ ...findSchemaPattern(schema, file, /\bUnsupported\s*\(/g, "Schema uses Unsupported(...)", "Unsupported field types are not fully represented by Prisma Client and may need manual handling.", "Confirm the field is intentional and document how application code and migrations handle it."),
82
+ ...findSchemaPattern(schema, file, /\bdbgenerated\s*\(/g, "Schema uses dbgenerated(...)", "Database-generated defaults can behave differently across providers and may hide database-specific behavior.", "Verify the expression in every target database and cover it with migration and application tests."),
83
+ ];
84
+ for (const model of parseModels(schema)) {
85
+ const fieldNames = new Set(model.fields.map((field) => field.name));
86
+ const deletedAt = model.fields.find((field) => field.name === "deletedAt");
87
+ const uniqueFields = model.fields.filter((field) => /(?:^|\s)@unique(?:\s|$|\()/i.test(field.source));
88
+ if (deletedAt && uniqueFields.length > 0) {
89
+ findings.push({
90
+ severity: "medium",
91
+ file,
92
+ line: uniqueFields[0].line,
93
+ title: `${model.name} combines soft deletion with unique fields`,
94
+ explanation: `The model has deletedAt and unique field(s): ${uniqueFields.map((field) => field.name).join(", ")}. Soft-deleted rows can continue to block reuse of those values.`,
95
+ suggestedFix: "Review whether uniqueness should include deletion state or be enforced with a database-specific partial unique index.",
96
+ });
97
+ }
98
+ for (const tenantField of tenantFields.filter((field) => fieldNames.has(field))) {
99
+ const indexed = model.indexes.some((index) => {
100
+ const indexedFields = index.match(/@@index\s*\(\s*\[([^\]]+)\]/)?.[1] ?? "";
101
+ return indexedFields
102
+ .split(",")
103
+ .map((field) => field.trim().split(/\s+/)[0])
104
+ .includes(tenantField);
105
+ });
106
+ if (!indexed) {
107
+ const field = model.fields.find((candidate) => candidate.name === tenantField);
108
+ findings.push({
109
+ severity: "medium",
110
+ file,
111
+ line: field?.line ?? model.line,
112
+ title: `${model.name}.${tenantField} has no matching index`,
113
+ explanation: "Tenant-scoped queries commonly filter by this field and may slow down as the table grows.",
114
+ suggestedFix: `Add @@index([${tenantField}]) or a compound index beginning with ${tenantField} that matches common queries.`,
115
+ });
116
+ }
117
+ }
118
+ if (includeLow && !fieldNames.has("createdAt")) {
119
+ findings.push({
120
+ severity: "low",
121
+ file,
122
+ line: model.line,
123
+ title: `${model.name} is missing createdAt`,
124
+ explanation: "Creation timestamps are useful for auditing, debugging, and ordering records.",
125
+ suggestedFix: "Consider adding createdAt DateTime @default(now()).",
126
+ });
127
+ }
128
+ if (includeLow && !fieldNames.has("updatedAt")) {
129
+ findings.push({
130
+ severity: "low",
131
+ file,
132
+ line: model.line,
133
+ title: `${model.name} is missing updatedAt`,
134
+ explanation: "Update timestamps are useful for auditing changes and cache synchronization.",
135
+ suggestedFix: "Consider adding updatedAt DateTime @updatedAt.",
136
+ });
137
+ }
138
+ if (includeLow && !fieldNames.has("deletedAt")) {
139
+ findings.push({
140
+ severity: "low",
141
+ file,
142
+ line: model.line,
143
+ title: `${model.name} is missing deletedAt`,
144
+ explanation: "A soft-delete timestamp can help preserve records that should not be removed immediately.",
145
+ suggestedFix: "If this model needs soft deletion, consider adding deletedAt DateTime?.",
146
+ });
147
+ }
148
+ const hasTenantField = tenantFields.some((field) => fieldNames.has(field));
149
+ if (includeLow &&
150
+ tenantOwnedModelNames.has(model.name.toLowerCase()) &&
151
+ !hasTenantField) {
152
+ findings.push({
153
+ severity: "low",
154
+ file,
155
+ line: model.line,
156
+ title: `${model.name} may be missing tenant ownership`,
157
+ explanation: "The model name suggests tenant-owned business data, but no common tenant field was found.",
158
+ suggestedFix: "If this data is tenant-owned, add an appropriate tenant identifier and index it. Otherwise, document that the model is global.",
159
+ });
160
+ }
161
+ }
162
+ return findings;
163
+ }
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.checkSql = checkSql;
4
+ const rules = [
5
+ {
6
+ severity: "high",
7
+ pattern: /\bDROP\s+TABLE\b/gi,
8
+ title: "Migration drops a table",
9
+ explanation: "Dropping a table permanently removes its data and can break dependent queries.",
10
+ suggestedFix: "Confirm the table is no longer needed, back up its data, and use a staged removal when possible.",
11
+ },
12
+ {
13
+ severity: "high",
14
+ pattern: /\bDROP\s+COLUMN\b/gi,
15
+ title: "Migration drops a column",
16
+ explanation: "Dropping a column permanently removes its stored data and may break older application versions.",
17
+ suggestedFix: "Stop reading and writing the column first, deploy that change, then remove the column in a later migration.",
18
+ },
19
+ {
20
+ severity: "high",
21
+ pattern: /\bTRUNCATE(?:\s+TABLE)?\b/gi,
22
+ title: "Migration truncates data",
23
+ explanation: "TRUNCATE removes all rows from a table and is difficult to reverse.",
24
+ suggestedFix: "Verify that full data deletion is intentional and ensure a tested backup or recovery plan exists.",
25
+ },
26
+ {
27
+ severity: "medium",
28
+ pattern: /\bCREATE\s+EXTENSION\b/gi,
29
+ title: "Migration creates a database extension",
30
+ explanation: "Database extensions may require elevated permissions or may not be available in every environment.",
31
+ suggestedFix: "Confirm the extension is supported and provisioned in each target database environment.",
32
+ },
33
+ {
34
+ severity: "medium",
35
+ pattern: /\bDROP\s+EXTENSION\b/gi,
36
+ title: "Migration drops a database extension",
37
+ explanation: "Removing an extension can break database objects or application features that depend on it.",
38
+ suggestedFix: "Identify dependent objects and features before removing the extension.",
39
+ },
40
+ ];
41
+ function lineNumberAt(text, index) {
42
+ return text.slice(0, index).split("\n").length;
43
+ }
44
+ function findingsForRule(sql, file, rule) {
45
+ const findings = [];
46
+ rule.pattern.lastIndex = 0;
47
+ for (const match of sql.matchAll(rule.pattern)) {
48
+ findings.push({
49
+ severity: rule.severity,
50
+ file,
51
+ line: lineNumberAt(sql, match.index ?? 0),
52
+ title: rule.title,
53
+ explanation: rule.explanation,
54
+ suggestedFix: rule.suggestedFix,
55
+ });
56
+ }
57
+ return findings;
58
+ }
59
+ function findDeletesWithoutWhere(sql, file) {
60
+ const findings = [];
61
+ const statementPattern = /\bDELETE\s+FROM\b[^;]*/gi;
62
+ for (const match of sql.matchAll(statementPattern)) {
63
+ const statement = match[0];
64
+ if (!/\bWHERE\b/i.test(statement)) {
65
+ findings.push({
66
+ severity: "high",
67
+ file,
68
+ line: lineNumberAt(sql, match.index ?? 0),
69
+ title: "DELETE FROM has no WHERE clause",
70
+ explanation: "A DELETE statement without a WHERE clause removes every row from the target table.",
71
+ suggestedFix: "Add a narrowly scoped WHERE clause, or explicitly verify and document that deleting all rows is intended.",
72
+ });
73
+ }
74
+ }
75
+ return findings;
76
+ }
77
+ function findColumnTypeChanges(sql, file) {
78
+ const findings = [];
79
+ const pattern = /\bALTER\s+TABLE\b[^;]*?\bALTER\s+COLUMN\s+(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|\w+)\s+(?:(?:SET\s+DATA\s+)|(?:SET\s+))?TYPE\b/gi;
80
+ for (const match of sql.matchAll(pattern)) {
81
+ findings.push({
82
+ severity: "high",
83
+ file,
84
+ line: lineNumberAt(sql, match.index ?? 0),
85
+ title: "Migration changes a column type",
86
+ explanation: "Changing a column type can rewrite or lock a table and may fail when existing values cannot be converted.",
87
+ suggestedFix: "Test the conversion on production-like data and consider a staged add, backfill, and swap migration.",
88
+ });
89
+ }
90
+ return findings;
91
+ }
92
+ function checkSql(sql, file) {
93
+ return [
94
+ ...rules.flatMap((rule) => findingsForRule(sql, file, rule)),
95
+ ...findColumnTypeChanges(sql, file),
96
+ ...findDeletesWithoutWhere(sql, file),
97
+ ];
98
+ }
@@ -0,0 +1,18 @@
1
+ name: Prisma migration guard
2
+
3
+ on:
4
+ pull_request:
5
+
6
+ jobs:
7
+ prisma-guard:
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ with:
12
+ fetch-depth: 0
13
+ - uses: actions/setup-node@v4
14
+ with:
15
+ node-version: 22
16
+ cache: npm
17
+ - run: npm install
18
+ - run: npx prisma-guard-lite --since origin/main --fail-on high
@@ -0,0 +1,32 @@
1
+ # Prisma Guard Lite Report
2
+
3
+ ## Summary
4
+
5
+ - **Scan mode:** staged migrations
6
+ - **Files scanned:** 1 migration file
7
+
8
+ | Severity | Count |
9
+ | --- | ---: |
10
+ | High | 0 |
11
+ | Medium | 0 |
12
+ | Low | 0 |
13
+
14
+ ## HIGH Findings
15
+
16
+ No findings.
17
+
18
+ ## MEDIUM Findings
19
+
20
+ No findings.
21
+
22
+ ## LOW Findings
23
+
24
+ No findings.
25
+
26
+ ## Suggested Next Steps
27
+
28
+ 1. Review the migration as part of the normal deployment process.
29
+ 2. Test it against production-like data.
30
+ 3. Confirm backups and rollback procedures are ready.
31
+
32
+ > Prisma Guard Lite is a heuristic scanner. It does not guarantee database safety.
@@ -0,0 +1,52 @@
1
+ # Prisma Guard Lite Report
2
+
3
+ ## Summary
4
+
5
+ - **Scan mode:** latest migration
6
+ - **Files scanned:** 1 migration file
7
+
8
+ | Severity | Count |
9
+ | --- | ---: |
10
+ | High | 2 |
11
+ | Medium | 1 |
12
+ | Low | 0 |
13
+
14
+ ## HIGH Findings
15
+
16
+ ### 1. Migration drops a column
17
+
18
+ - **Severity:** HIGH
19
+ - **File:** `prisma/migrations/20260623090000_remove_legacy/migration.sql`
20
+ - **Line:** 4
21
+ - **Explanation:** Dropping a column permanently removes its stored data and may break older application versions.
22
+ - **Suggested fix:** Stop reading and writing the column first, deploy that change, then remove the column in a later migration.
23
+
24
+ ### 2. DELETE FROM has no WHERE clause
25
+
26
+ - **Severity:** HIGH
27
+ - **File:** `prisma/migrations/20260623090000_remove_legacy/migration.sql`
28
+ - **Line:** 8
29
+ - **Explanation:** A DELETE statement without a WHERE clause removes every row from the target table.
30
+ - **Suggested fix:** Add a narrowly scoped WHERE clause, or explicitly verify and document that deleting all rows is intended.
31
+
32
+ ## MEDIUM Findings
33
+
34
+ ### 1. Migration creates a database extension
35
+
36
+ - **Severity:** MEDIUM
37
+ - **File:** `prisma/migrations/20260623090000_remove_legacy/migration.sql`
38
+ - **Line:** 1
39
+ - **Explanation:** Database extensions may require elevated permissions or may not be available in every environment.
40
+ - **Suggested fix:** Confirm the extension is supported and provisioned in each target database environment.
41
+
42
+ ## LOW Findings
43
+
44
+ No findings.
45
+
46
+ ## Suggested Next Steps
47
+
48
+ 1. Review all high-severity findings before deployment.
49
+ 2. Test risky migrations against a recent production-like backup.
50
+ 3. Confirm backups and rollback procedures are ready.
51
+
52
+ > Prisma Guard Lite is a heuristic scanner. It does not guarantee database safety.
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "prisma-guard-lite",
3
+ "version": "0.1.0",
4
+ "description": "Pre-deploy migration risk checker for Prisma projects",
5
+ "keywords": [
6
+ "prisma",
7
+ "prisma-migrate",
8
+ "database",
9
+ "migrations",
10
+ "migration-checker",
11
+ "ci",
12
+ "postgres",
13
+ "typescript"
14
+ ],
15
+ "license": "MIT",
16
+ "bin": {
17
+ "prisma-guard-lite": "dist/index.js"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "LICENSE",
23
+ "VALIDATION.md",
24
+ "examples"
25
+ ],
26
+ "scripts": {
27
+ "dev": "tsx src/index.ts",
28
+ "build": "tsc"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.0.0",
32
+ "tsx": "^4.19.0",
33
+ "typescript": "^5.7.0"
34
+ },
35
+ "engines": {
36
+ "node": ">=18"
37
+ }
38
+ }