mandrel-platform 0.13.0 → 0.14.2
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 +203 -7
- package/config/dependency-cruiser.base.json +121 -0
- package/config/edge-security/allowlist.mjs +138 -0
- package/config/edge-security/cors-astro.mjs +131 -0
- package/config/edge-security/cors-hono.mjs +111 -0
- package/config/edge-security/index.mjs +34 -0
- package/config/edge-security/rate-limit.mjs +219 -0
- package/config/edge-security/security-headers.mjs +148 -0
- package/config/knip.base.json +7 -0
- package/config/lighthouse.base.json +27 -0
- package/config/size-limit.base.json +6 -0
- package/config/stryker.base.json +15 -0
- package/package.json +23 -2
- package/scripts/check-action-pins.mjs +344 -0
- package/scripts/check-action-pins.test.mjs +240 -0
- package/scripts/check-coverage-threshold.mjs +300 -0
- package/scripts/check-coverage-threshold.test.mjs +350 -0
- package/scripts/check-destructive-migration.mjs +313 -0
- package/scripts/check-destructive-migration.test.mjs +183 -0
- package/scripts/check-pin-drift.mjs +361 -17
- package/scripts/check-pin-drift.test.mjs +344 -1
- package/scripts/edge-security.test.mjs +300 -0
- package/scripts/pin-drift-consumers.json +2 -0
- package/scripts/platform-repair.mjs +748 -0
- package/scripts/platform-repair.test.mjs +458 -0
- package/scripts/platform-sync.mjs +0 -0
- package/scripts/platform-sync.test.mjs +0 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-destructive-migration.mjs
|
|
4
|
+
*
|
|
5
|
+
* Destructive-migration label guard (Story #111).
|
|
6
|
+
*
|
|
7
|
+
* Platformizes the destructive-migration guard that domio and athportal each
|
|
8
|
+
* hand-rolled and that swarm-os was missing entirely. A PR that introduces a
|
|
9
|
+
* destructive database migration (a `DROP`, an `ALTER ... DROP`, or a
|
|
10
|
+
* destructive drizzle-kit operation in a changed migration file) is BLOCKED
|
|
11
|
+
* unless a reviewer applies an explicit acknowledgement label
|
|
12
|
+
* (default: `migration:destructive-ok`).
|
|
13
|
+
*
|
|
14
|
+
* This is the static, PR-time half of the contract: it inspects the *changed
|
|
15
|
+
* migration files* for a destructive SQL/drizzle signal — it does NOT
|
|
16
|
+
* introspect a live database. The override is an explicit, human-applied PR
|
|
17
|
+
* label, so the destructive change still ships, but only with a deliberate
|
|
18
|
+
* acknowledgement on the record.
|
|
19
|
+
*
|
|
20
|
+
* Detection is a best-of-breed UNION of the two local guards it generalizes:
|
|
21
|
+
* • `DROP TABLE` / `DROP COLUMN` / `DROP INDEX` / `DROP SCHEMA` / `DROP …`
|
|
22
|
+
* • `ALTER TABLE … DROP COLUMN` / `ALTER TABLE … DROP CONSTRAINT`
|
|
23
|
+
* • `TRUNCATE`
|
|
24
|
+
* • drizzle-kit destructive ops emitted into a migration:
|
|
25
|
+
* `.dropTable(` / `.dropColumn(` / `.dropIndex(` / `.dropConstraint(`
|
|
26
|
+
* • a drizzle journal/breakpoint marker paired with a `DROP` statement
|
|
27
|
+
* Comment lines (`--`, `/* … */`, `//`) are stripped before matching so a
|
|
28
|
+
* `DROP` mentioned only in a comment does not trip the guard.
|
|
29
|
+
*
|
|
30
|
+
* The guard only inspects files whose path matches a migration glob (default
|
|
31
|
+
* `**/migrations/**` and `**/drizzle/**` plus a `*.sql` tail), so an
|
|
32
|
+
* unrelated source file mentioning `DROP` in a string never blocks a PR.
|
|
33
|
+
*
|
|
34
|
+
* --------------------------------------------------------------------------
|
|
35
|
+
* Usage (CLI — exit code is the gate):
|
|
36
|
+
* node scripts/check-destructive-migration.mjs \
|
|
37
|
+
* --changed-files <file-with-one-path-per-line> \
|
|
38
|
+
* [--label-present] \
|
|
39
|
+
* [--migration-glob '**/migrations/**,**/drizzle/**'] \
|
|
40
|
+
* [--repo-root <dir>]
|
|
41
|
+
*
|
|
42
|
+
* • --changed-files Path to a newline-delimited list of PR-changed files
|
|
43
|
+
* (e.g. the output of `git diff --name-only base..head`).
|
|
44
|
+
* Use `-` to read the list from stdin.
|
|
45
|
+
* • --label-present Pass when the override acknowledgement label is on the
|
|
46
|
+
* PR. Overrides a destructive finding (exit 0 with a
|
|
47
|
+
* warning) instead of blocking.
|
|
48
|
+
* • --migration-glob Comma-separated migration path globs. Default
|
|
49
|
+
* `**/migrations/**,**/drizzle/**`.
|
|
50
|
+
* • --repo-root Root to resolve changed-file paths against. Default cwd.
|
|
51
|
+
*
|
|
52
|
+
* Exit codes:
|
|
53
|
+
* 0 — no destructive migration in the changed set, OR a destructive
|
|
54
|
+
* migration is present AND the override label is applied.
|
|
55
|
+
* 1 — a destructive migration is present and the override label is absent
|
|
56
|
+
* (the blocking case; the offending files + signals are named on stderr).
|
|
57
|
+
* 2 — a usage / IO error (bad args, unreadable file).
|
|
58
|
+
*
|
|
59
|
+
* The label name is part of the documented contract — see
|
|
60
|
+
* docs/reusable-workflows.md (`pr-quality.yml` → migration guard).
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
import { readFileSync } from "node:fs";
|
|
64
|
+
import { resolve } from "node:path";
|
|
65
|
+
|
|
66
|
+
// The override acknowledgement label. Documented in docs/reusable-workflows.md.
|
|
67
|
+
export const DEFAULT_OVERRIDE_LABEL = "migration:destructive-ok";
|
|
68
|
+
|
|
69
|
+
// Default migration path globs. A changed file must match one of these for the
|
|
70
|
+
// destructive-signal scan to even look at it.
|
|
71
|
+
export const DEFAULT_MIGRATION_GLOBS = ["**/migrations/**", "**/drizzle/**"];
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Pure helpers (exported for the self-test)
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Convert a restricted glob (supporting `**`, `*`, and literals) into a RegExp.
|
|
79
|
+
* `**` matches across path separators; `*` matches within a single segment.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} glob
|
|
82
|
+
* @returns {RegExp}
|
|
83
|
+
*/
|
|
84
|
+
export function globToRegExp(glob) {
|
|
85
|
+
let re = "";
|
|
86
|
+
for (let i = 0; i < glob.length; i++) {
|
|
87
|
+
const c = glob[i];
|
|
88
|
+
if (c === "*") {
|
|
89
|
+
if (glob[i + 1] === "*") {
|
|
90
|
+
// `**` → any chars including `/`. Consume an optional trailing slash so
|
|
91
|
+
// `**/migrations/**` matches `migrations/x` (no leading dir) too.
|
|
92
|
+
re += ".*";
|
|
93
|
+
i++;
|
|
94
|
+
if (glob[i + 1] === "/") i++;
|
|
95
|
+
} else {
|
|
96
|
+
// single `*` → any chars except `/`
|
|
97
|
+
re += "[^/]*";
|
|
98
|
+
}
|
|
99
|
+
} else if (".+?^${}()|[]\\".includes(c)) {
|
|
100
|
+
re += `\\${c}`;
|
|
101
|
+
} else {
|
|
102
|
+
re += c;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return new RegExp(`^${re}$`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Is `filePath` a migration file per the supplied globs? A `.sql` file is also
|
|
110
|
+
* always treated as a migration candidate (drizzle/raw-SQL migrations land as
|
|
111
|
+
* `*.sql`), so a bare `0007_drop_users.sql` is covered even outside a
|
|
112
|
+
* `migrations/` directory.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} filePath Repo-relative path (forward slashes).
|
|
115
|
+
* @param {string[]} globs
|
|
116
|
+
* @returns {boolean}
|
|
117
|
+
*/
|
|
118
|
+
export function isMigrationFile(filePath, globs = DEFAULT_MIGRATION_GLOBS) {
|
|
119
|
+
const norm = filePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
120
|
+
if (norm.endsWith(".sql")) return true;
|
|
121
|
+
return globs.some((g) => globToRegExp(g).test(norm));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Strip SQL / JS comments from a single line so a `DROP` that appears only in a
|
|
126
|
+
* comment does not trip the guard. Handles `--`, `//`, and a `/* … */` opened
|
|
127
|
+
* and closed on the same line. (Multi-line block comments are rare in migration
|
|
128
|
+
* files and conservatively left in — a false positive there is acknowledgeable
|
|
129
|
+
* via the override label.)
|
|
130
|
+
*
|
|
131
|
+
* @param {string} line
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
export function stripComments(line) {
|
|
135
|
+
let out = line.replace(/\/\*.*?\*\//g, " ");
|
|
136
|
+
const dashIdx = out.indexOf("--");
|
|
137
|
+
if (dashIdx !== -1) out = out.slice(0, dashIdx);
|
|
138
|
+
const slashIdx = out.indexOf("//");
|
|
139
|
+
if (slashIdx !== -1) out = out.slice(0, slashIdx);
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The destructive-signal matchers. Each entry names the signal it detects so a
|
|
144
|
+
// block message can tell the reviewer exactly what tripped the guard. Order is
|
|
145
|
+
// most-specific-first only for readability; all are tested per line.
|
|
146
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
147
|
+
{ signal: "ALTER TABLE … DROP", re: /\bALTER\s+TABLE\b[\s\S]*?\bDROP\b/i },
|
|
148
|
+
{
|
|
149
|
+
signal: "DROP statement",
|
|
150
|
+
// DROP TABLE/COLUMN/INDEX/SCHEMA/CONSTRAINT/VIEW/DATABASE/TYPE …
|
|
151
|
+
re: /\bDROP\s+(TABLE|COLUMN|INDEX|SCHEMA|CONSTRAINT|VIEW|DATABASE|TYPE|SEQUENCE|TRIGGER|FUNCTION)\b/i,
|
|
152
|
+
},
|
|
153
|
+
{ signal: "TRUNCATE", re: /\bTRUNCATE\b/i },
|
|
154
|
+
{
|
|
155
|
+
signal: "drizzle destructive op",
|
|
156
|
+
re: /\.(dropTable|dropColumn|dropIndex|dropConstraint|dropForeignKey|dropPrimaryKey|dropUnique)\s*\(/,
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Scan a single migration file's text for destructive signals.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} text
|
|
164
|
+
* @returns {string[]} De-duplicated list of signal names found (empty = clean).
|
|
165
|
+
*/
|
|
166
|
+
export function scanMigrationText(text) {
|
|
167
|
+
const found = new Set();
|
|
168
|
+
for (const rawLine of text.split("\n")) {
|
|
169
|
+
const line = stripComments(rawLine);
|
|
170
|
+
if (!line.trim()) continue;
|
|
171
|
+
for (const { signal, re } of DESTRUCTIVE_PATTERNS) {
|
|
172
|
+
if (re.test(line)) found.add(signal);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return [...found];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Core detection over a set of changed files. Pure: the caller supplies a
|
|
180
|
+
* `readFile` seam so the self-test never touches the filesystem.
|
|
181
|
+
*
|
|
182
|
+
* @param {object} opts
|
|
183
|
+
* @param {string[]} opts.changedFiles Repo-relative changed paths.
|
|
184
|
+
* @param {(path: string) => string} opts.readFile Reads a file's text.
|
|
185
|
+
* @param {string[]} [opts.globs] Migration path globs.
|
|
186
|
+
* @returns {{ destructive: boolean, findings: Array<{file: string, signals: string[]}> }}
|
|
187
|
+
*/
|
|
188
|
+
export function detectDestructiveMigrations({ changedFiles, readFile, globs = DEFAULT_MIGRATION_GLOBS }) {
|
|
189
|
+
const findings = [];
|
|
190
|
+
for (const file of changedFiles) {
|
|
191
|
+
if (!isMigrationFile(file, globs)) continue;
|
|
192
|
+
let text;
|
|
193
|
+
try {
|
|
194
|
+
text = readFile(file);
|
|
195
|
+
} catch {
|
|
196
|
+
// A deleted migration file shows up in the changed set but can't be read
|
|
197
|
+
// at head. Deleting a migration file is itself a destructive signal, so
|
|
198
|
+
// record it rather than silently passing.
|
|
199
|
+
findings.push({ file, signals: ["deleted migration file"] });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
const signals = scanMigrationText(text);
|
|
203
|
+
if (signals.length > 0) findings.push({ file, signals });
|
|
204
|
+
}
|
|
205
|
+
return { destructive: findings.length > 0, findings };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// CLI
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
function parseArgs(argv) {
|
|
213
|
+
const opts = {
|
|
214
|
+
changedFiles: null,
|
|
215
|
+
labelPresent: false,
|
|
216
|
+
globs: DEFAULT_MIGRATION_GLOBS,
|
|
217
|
+
repoRoot: process.cwd(),
|
|
218
|
+
};
|
|
219
|
+
for (let i = 0; i < argv.length; i++) {
|
|
220
|
+
const a = argv[i];
|
|
221
|
+
if (a === "--changed-files" && argv[i + 1]) {
|
|
222
|
+
opts.changedFiles = argv[++i];
|
|
223
|
+
} else if (a === "--label-present") {
|
|
224
|
+
opts.labelPresent = true;
|
|
225
|
+
} else if (a === "--migration-glob" && argv[i + 1]) {
|
|
226
|
+
opts.globs = argv[++i]
|
|
227
|
+
.split(",")
|
|
228
|
+
.map((g) => g.trim())
|
|
229
|
+
.filter(Boolean);
|
|
230
|
+
} else if (a === "--repo-root" && argv[i + 1]) {
|
|
231
|
+
opts.repoRoot = resolve(argv[++i]);
|
|
232
|
+
} else if (a === "--help" || a === "-h") {
|
|
233
|
+
opts.help = true;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return opts;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function readChangedList(source) {
|
|
240
|
+
const raw =
|
|
241
|
+
source === "-"
|
|
242
|
+
? readFileSync(0, "utf8")
|
|
243
|
+
: readFileSync(resolve(source), "utf8");
|
|
244
|
+
return raw
|
|
245
|
+
.split("\n")
|
|
246
|
+
.map((l) => l.trim())
|
|
247
|
+
.filter(Boolean);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function main() {
|
|
251
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
252
|
+
if (opts.help) {
|
|
253
|
+
process.stdout.write(
|
|
254
|
+
"Usage: node scripts/check-destructive-migration.mjs --changed-files <path|-> " +
|
|
255
|
+
"[--label-present] [--migration-glob <csv>] [--repo-root <dir>]\n"
|
|
256
|
+
);
|
|
257
|
+
process.exit(0);
|
|
258
|
+
}
|
|
259
|
+
if (!opts.changedFiles) {
|
|
260
|
+
process.stderr.write(
|
|
261
|
+
"[check-destructive-migration] ERROR: --changed-files <path|-> is required.\n"
|
|
262
|
+
);
|
|
263
|
+
process.exit(2);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let changedFiles;
|
|
267
|
+
try {
|
|
268
|
+
changedFiles = readChangedList(opts.changedFiles);
|
|
269
|
+
} catch (err) {
|
|
270
|
+
process.stderr.write(
|
|
271
|
+
`[check-destructive-migration] ERROR: cannot read changed-files list: ${err.message}\n`
|
|
272
|
+
);
|
|
273
|
+
process.exit(2);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const { destructive, findings } = detectDestructiveMigrations({
|
|
277
|
+
changedFiles,
|
|
278
|
+
globs: opts.globs,
|
|
279
|
+
readFile: (file) => readFileSync(resolve(opts.repoRoot, file), "utf8"),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
if (!destructive) {
|
|
283
|
+
process.stdout.write(
|
|
284
|
+
"✅ No destructive migration detected in the changed files.\n"
|
|
285
|
+
);
|
|
286
|
+
process.exit(0);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const summary = findings
|
|
290
|
+
.map((f) => ` • ${f.file} → ${f.signals.join(", ")}`)
|
|
291
|
+
.join("\n");
|
|
292
|
+
|
|
293
|
+
if (opts.labelPresent) {
|
|
294
|
+
process.stdout.write(
|
|
295
|
+
`⚠️ Destructive migration detected, but the override label ` +
|
|
296
|
+
`'${DEFAULT_OVERRIDE_LABEL}' is applied — allowing.\n${summary}\n`
|
|
297
|
+
);
|
|
298
|
+
process.exit(0);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
process.stderr.write(
|
|
302
|
+
`❌ Destructive migration detected and the override label ` +
|
|
303
|
+
`'${DEFAULT_OVERRIDE_LABEL}' is NOT applied — blocking.\n${summary}\n\n` +
|
|
304
|
+
`To proceed, a reviewer must apply the '${DEFAULT_OVERRIDE_LABEL}' label ` +
|
|
305
|
+
`to acknowledge the destructive change, then re-run this check.\n`
|
|
306
|
+
);
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Only run the CLI when invoked directly, not when imported by the self-test.
|
|
311
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
312
|
+
main();
|
|
313
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* check-destructive-migration.test.mjs — node:test suite for the platformized
|
|
4
|
+
* destructive-migration label guard (Story #111).
|
|
5
|
+
*
|
|
6
|
+
* The guard's detection core (`detectDestructiveMigrations`) takes an injected
|
|
7
|
+
* `readFile` seam, so the whole signal-detection pipeline is exercised offline
|
|
8
|
+
* with in-memory fixtures — no filesystem, no `git`, no `gh`. This is the
|
|
9
|
+
* "validated via self-test" half of the Story's acceptance contract; the
|
|
10
|
+
* cross-repo smoke (pr-quality.yml consumed by the smoke repo) is the
|
|
11
|
+
* end-to-end half.
|
|
12
|
+
*
|
|
13
|
+
* Run: node scripts/check-destructive-migration.test.mjs (or `node --test scripts/`)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { test } from "node:test";
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_MIGRATION_GLOBS,
|
|
21
|
+
DEFAULT_OVERRIDE_LABEL,
|
|
22
|
+
detectDestructiveMigrations,
|
|
23
|
+
globToRegExp,
|
|
24
|
+
isMigrationFile,
|
|
25
|
+
scanMigrationText,
|
|
26
|
+
stripComments,
|
|
27
|
+
} from "./check-destructive-migration.mjs";
|
|
28
|
+
|
|
29
|
+
// Build a readFile seam from an in-memory { path: text } map.
|
|
30
|
+
function fakeReader(files) {
|
|
31
|
+
return (path) => {
|
|
32
|
+
if (!(path in files)) {
|
|
33
|
+
const err = new Error(`ENOENT: ${path}`);
|
|
34
|
+
err.code = "ENOENT";
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
return files[path];
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── globToRegExp / isMigrationFile ─────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
test("globToRegExp: `**` crosses path separators, `*` does not", () => {
|
|
44
|
+
assert.ok(globToRegExp("**/migrations/**").test("apps/db/migrations/0001.sql"));
|
|
45
|
+
assert.ok(globToRegExp("**/migrations/**").test("migrations/0001.sql"));
|
|
46
|
+
assert.ok(!globToRegExp("*.sql").test("db/x.sql")); // single * stays in-segment
|
|
47
|
+
assert.ok(globToRegExp("*.sql").test("x.sql"));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("isMigrationFile: matches migration globs and any .sql tail", () => {
|
|
51
|
+
assert.ok(isMigrationFile("apps/api/migrations/0007_drop.ts"));
|
|
52
|
+
assert.ok(isMigrationFile("drizzle/0001_init.sql"));
|
|
53
|
+
assert.ok(isMigrationFile("0007_drop_users.sql")); // bare .sql counts
|
|
54
|
+
assert.ok(!isMigrationFile("src/services/user.ts"));
|
|
55
|
+
assert.ok(!isMigrationFile("README.md"));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("isMigrationFile: honours a custom glob set", () => {
|
|
59
|
+
const globs = ["**/db/changes/**"];
|
|
60
|
+
assert.ok(isMigrationFile("pkg/db/changes/x.ts", globs));
|
|
61
|
+
assert.ok(!isMigrationFile("pkg/migrations/x.ts", globs)); // default glob not in set
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ── stripComments ──────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
test("stripComments: removes -- , // and inline /* */ comments", () => {
|
|
67
|
+
assert.equal(stripComments("CREATE TABLE x; -- DROP TABLE y").trim(), "CREATE TABLE x;");
|
|
68
|
+
assert.equal(stripComments("ok(); // DROP TABLE y").trim(), "ok();");
|
|
69
|
+
assert.equal(stripComments("a /* DROP TABLE y */ b").replace(/\s+/g, " ").trim(), "a b");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ── scanMigrationText: the destructive signal union ────────────────────────
|
|
73
|
+
|
|
74
|
+
test("scanMigrationText: detects DROP TABLE / COLUMN / INDEX / etc.", () => {
|
|
75
|
+
assert.deepEqual(scanMigrationText("DROP TABLE users;"), ["DROP statement"]);
|
|
76
|
+
assert.deepEqual(scanMigrationText("drop column email"), ["DROP statement"]);
|
|
77
|
+
assert.deepEqual(scanMigrationText("DROP INDEX idx_users_email;"), ["DROP statement"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("scanMigrationText: detects ALTER TABLE … DROP", () => {
|
|
81
|
+
const signals = scanMigrationText("ALTER TABLE users DROP COLUMN legacy_id;");
|
|
82
|
+
assert.ok(signals.includes("ALTER TABLE … DROP"));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("scanMigrationText: detects TRUNCATE", () => {
|
|
86
|
+
assert.deepEqual(scanMigrationText("TRUNCATE audit_log;"), ["TRUNCATE"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("scanMigrationText: detects drizzle destructive ops", () => {
|
|
90
|
+
assert.deepEqual(
|
|
91
|
+
scanMigrationText("await db.schema.dropColumn('users', 'legacy');"),
|
|
92
|
+
["drizzle destructive op"]
|
|
93
|
+
);
|
|
94
|
+
assert.deepEqual(
|
|
95
|
+
scanMigrationText("table.dropConstraint('fk_x')"),
|
|
96
|
+
["drizzle destructive op"]
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("scanMigrationText: clean migration yields no signals", () => {
|
|
101
|
+
assert.deepEqual(
|
|
102
|
+
scanMigrationText("CREATE TABLE users (id INTEGER PRIMARY KEY);\nADD COLUMN email TEXT;"),
|
|
103
|
+
[]
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("scanMigrationText: a DROP only in a comment does NOT trip the guard", () => {
|
|
108
|
+
assert.deepEqual(scanMigrationText("-- DROP TABLE users; (rolled back)"), []);
|
|
109
|
+
assert.deepEqual(scanMigrationText("// dropTable('users')"), []);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("scanMigrationText: de-duplicates repeated signals", () => {
|
|
113
|
+
const signals = scanMigrationText("DROP TABLE a;\nDROP TABLE b;");
|
|
114
|
+
assert.deepEqual(signals, ["DROP statement"]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ── detectDestructiveMigrations: end-to-end over a changed set ─────────────
|
|
118
|
+
|
|
119
|
+
test("detect: flags a destructive migration file", () => {
|
|
120
|
+
const files = { "db/migrations/0007_drop.sql": "DROP TABLE users;" };
|
|
121
|
+
const res = detectDestructiveMigrations({
|
|
122
|
+
changedFiles: Object.keys(files),
|
|
123
|
+
readFile: fakeReader(files),
|
|
124
|
+
});
|
|
125
|
+
assert.equal(res.destructive, true);
|
|
126
|
+
assert.equal(res.findings.length, 1);
|
|
127
|
+
assert.deepEqual(res.findings[0].signals, ["DROP statement"]);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("detect: ignores non-migration files even if they mention DROP", () => {
|
|
131
|
+
const files = {
|
|
132
|
+
"src/sql-builder.ts": "const q = 'DROP TABLE foo';",
|
|
133
|
+
"docs/notes.md": "We will DROP COLUMN later.",
|
|
134
|
+
};
|
|
135
|
+
const res = detectDestructiveMigrations({
|
|
136
|
+
changedFiles: Object.keys(files),
|
|
137
|
+
readFile: fakeReader(files),
|
|
138
|
+
});
|
|
139
|
+
assert.equal(res.destructive, false);
|
|
140
|
+
assert.deepEqual(res.findings, []);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("detect: clean migration passes", () => {
|
|
144
|
+
const files = { "db/migrations/0008_add.sql": "ALTER TABLE users ADD COLUMN nickname TEXT;" };
|
|
145
|
+
const res = detectDestructiveMigrations({
|
|
146
|
+
changedFiles: Object.keys(files),
|
|
147
|
+
readFile: fakeReader(files),
|
|
148
|
+
});
|
|
149
|
+
assert.equal(res.destructive, false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("detect: a deleted migration file is itself a destructive signal", () => {
|
|
153
|
+
const res = detectDestructiveMigrations({
|
|
154
|
+
changedFiles: ["db/migrations/0005_old.sql"],
|
|
155
|
+
readFile: () => {
|
|
156
|
+
throw new Error("ENOENT");
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
assert.equal(res.destructive, true);
|
|
160
|
+
assert.deepEqual(res.findings[0].signals, ["deleted migration file"]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("detect: mixed set reports only the destructive migration", () => {
|
|
164
|
+
const files = {
|
|
165
|
+
"db/migrations/0009_add.sql": "ALTER TABLE x ADD COLUMN y TEXT;",
|
|
166
|
+
"db/migrations/0010_drop.sql": "ALTER TABLE x DROP COLUMN z;",
|
|
167
|
+
"src/app.ts": "doStuff();",
|
|
168
|
+
};
|
|
169
|
+
const res = detectDestructiveMigrations({
|
|
170
|
+
changedFiles: Object.keys(files),
|
|
171
|
+
readFile: fakeReader(files),
|
|
172
|
+
});
|
|
173
|
+
assert.equal(res.destructive, true);
|
|
174
|
+
assert.equal(res.findings.length, 1);
|
|
175
|
+
assert.equal(res.findings[0].file, "db/migrations/0010_drop.sql");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// ── Contract constants ─────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
test("override label and default globs are the documented contract values", () => {
|
|
181
|
+
assert.equal(DEFAULT_OVERRIDE_LABEL, "migration:destructive-ok");
|
|
182
|
+
assert.deepEqual(DEFAULT_MIGRATION_GLOBS, ["**/migrations/**", "**/drizzle/**"]);
|
|
183
|
+
});
|