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.
@@ -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
+ });