mandrel-platform 1.1.0 → 1.3.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.
@@ -27,6 +27,18 @@
27
27
  * Comment lines (`--`, `/* … *​/`, `//`) are stripped before matching so a
28
28
  * `DROP` mentioned only in a comment does not trip the guard.
29
29
  *
30
+ * ONE carve-out, and only one (Story #367): a `DROP INDEX <name>` whose index
31
+ * the SAME migration file recreates LATER in the file (`CREATE [UNIQUE] INDEX
32
+ * <name>`) is **lossless** and does not trip the guard. Narrowing a partial
33
+ * index cannot be expressed any other way on SQLite — it is necessarily a drop
34
+ * followed immediately by a create — so an ordinary, reversible migration was
35
+ * demanding a human acknowledgement label. The carve-out is deliberately
36
+ * narrow and fails closed: a drop with no matching recreate, a recreate that
37
+ * appears BEFORE the drop (the index is still gone at the end of the
38
+ * migration), a recreate in a different file, an unparseable index name, and
39
+ * every non-INDEX drop all still block. No table, column, constraint, or
40
+ * TRUNCATE detection is relaxed.
41
+ *
30
42
  * The guard only inspects files whose path matches a migration glob (default
31
43
  * `**​/migrations/**` and `**​/drizzle/**` plus a `*.sql` tail), so an
32
44
  * unrelated source file mentioning `DROP` in a string never blocks a PR.
@@ -133,9 +145,11 @@ export function isMigrationFile(filePath, globs = DEFAULT_MIGRATION_GLOBS) {
133
145
  /**
134
146
  * Strip SQL / JS comments from a single line so a `DROP` that appears only in a
135
147
  * comment does not trip the guard. Handles `--`, `//`, and a `/* … *​/` opened
136
- * and closed on the same line. (Multi-line block comments are rare in migration
137
- * files and conservatively left in a false positive there is acknowledgeable
138
- * via the override label.)
148
+ * and closed on the same line. A multi-line block comment's residue is left in
149
+ * DELIBERATELY on this side: it can only cause a false positive, which the
150
+ * override label acknowledges. It is `maskNonExecutable` — applied only to the
151
+ * text the RECREATE scan reads — that must be exact, because there the same
152
+ * residue would cause a false NEGATIVE.
139
153
  *
140
154
  * @param {string} line
141
155
  * @returns {string}
@@ -149,16 +163,59 @@ export function stripComments(line) {
149
163
  return out;
150
164
  }
151
165
 
152
- // The destructive-signal matchers. Each entry names the signal it detects so a
153
- // block message can tell the reviewer exactly what tripped the guard. Order is
154
- // most-specific-first only for readability; all are tested per line.
166
+ /**
167
+ * The whole-text, LENGTH-PRESERVING counterpart of {@link stripComments}: same
168
+ * comment syntaxes, blanked in place rather than sliced out, so offsets stay
169
+ * comparable across every scan derived from the same source text.
170
+ *
171
+ * @param {string} text
172
+ * @returns {string} Same length as `text`.
173
+ */
174
+ export function maskComments(text) {
175
+ return text
176
+ .split("\n")
177
+ .map((line) => {
178
+ let out = line.replace(/\/\*.*?\*\//g, (m) => " ".repeat(m.length));
179
+ const candidates = [out.indexOf("--"), out.indexOf("//")].filter((i) => i !== -1);
180
+ if (candidates.length > 0) {
181
+ const cut = Math.min(...candidates);
182
+ out = out.slice(0, cut) + " ".repeat(out.length - cut);
183
+ }
184
+ return out;
185
+ })
186
+ .join("\n");
187
+ }
188
+
189
+ // The direction markers of a bidirectional migration — goose, sql-migrate, and
190
+ // dbmate. Each one starts a section that runs independently of its neighbours.
191
+ const SECTION_DIRECTIVE_RE =
192
+ /^[ \t]*--[ \t]*(?:\+goose\s+(?:Up|Down)|\+migrate\s+(?:Up|Down)|migrate:(?:up|down))\b/gim;
193
+
194
+ /**
195
+ * The offsets at which a bidirectional migration changes section. A recreate on
196
+ * the far side of one of these does not run in the same direction as the drop:
197
+ * a `CREATE INDEX` in the Down section cannot un-drop an index the Up section
198
+ * dropped. Read from the RAW text — the markers are `--` comments, so anything
199
+ * that has already masked comments has erased them.
200
+ *
201
+ * @param {string} text
202
+ * @returns {number[]} Ascending offsets.
203
+ */
204
+ export function sectionBoundaries(text) {
205
+ const re = new RegExp(SECTION_DIRECTIVE_RE.source, "gim");
206
+ const offsets = [];
207
+ let m;
208
+ while ((m = re.exec(text)) !== null) offsets.push(m.index);
209
+ return offsets;
210
+ }
211
+
212
+ // The destructive-signal matchers evaluated per line. `DROP <object>` is NOT
213
+ // in this list — it is scanned over the whole file so a `DROP INDEX` can be
214
+ // paired with a later recreate (see scanDropStatements below). Each entry names
215
+ // the signal it detects so a block message can tell the reviewer exactly what
216
+ // tripped the guard.
155
217
  const DESTRUCTIVE_PATTERNS = [
156
218
  { signal: "ALTER TABLE … DROP", re: /\bALTER\s+TABLE\b[\s\S]*?\bDROP\b/i },
157
- {
158
- signal: "DROP statement",
159
- // DROP TABLE/COLUMN/INDEX/SCHEMA/CONSTRAINT/VIEW/DATABASE/TYPE …
160
- re: /\bDROP\s+(TABLE|COLUMN|INDEX|SCHEMA|CONSTRAINT|VIEW|DATABASE|TYPE|SEQUENCE|TRIGGER|FUNCTION)\b/i,
161
- },
162
219
  { signal: "TRUNCATE", re: /\bTRUNCATE\b/i },
163
220
  {
164
221
  signal: "drizzle destructive op",
@@ -166,6 +223,213 @@ const DESTRUCTIVE_PATTERNS = [
166
223
  },
167
224
  ];
168
225
 
226
+ // One identifier segment: bare, or quoted with "…", `…`, or […].
227
+ const IDENT_SEGMENT_SOURCE = '(?:"[^"]+"|`[^`]+`|\\[[^\\]]+\\]|[A-Za-z_][A-Za-z0-9_$]*)';
228
+
229
+ // A possibly schema-qualified identifier — every segment may be quoted
230
+ // independently (`"public"."idx_x"`).
231
+ const IDENT_SOURCE = `${IDENT_SEGMENT_SOURCE}(?:\\.${IDENT_SEGMENT_SOURCE})*`;
232
+
233
+ // `DROP TABLE/COLUMN/INDEX/SCHEMA/CONSTRAINT/VIEW/DATABASE/TYPE …`. Scanned
234
+ // globally so every occurrence is judged, not just the first on a line.
235
+ const DROP_OBJECT_SOURCE =
236
+ "\\bDROP\\s+(TABLE|COLUMN|INDEX|SCHEMA|CONSTRAINT|VIEW|DATABASE|TYPE|SEQUENCE|TRIGGER|FUNCTION)\\b";
237
+
238
+ // The dropped index NAMES, anchored at the `DROP` that already matched.
239
+ // Tolerates postgres' `CONCURRENTLY` and the `IF EXISTS` guard, and captures
240
+ // the WHOLE comma-separated list — `DROP INDEX a, b;` is valid postgres, and
241
+ // excusing it on the strength of the first name alone would let `b` be dropped
242
+ // with no recreate and no acknowledgement.
243
+ const DROP_INDEX_LIST_RE = new RegExp(
244
+ `^DROP\\s+INDEX\\s+(?:CONCURRENTLY\\s+)?(?:IF\\s+EXISTS\\s+)?(${IDENT_SOURCE}(?:\\s*,\\s*${IDENT_SOURCE})*)`,
245
+ "i"
246
+ );
247
+
248
+ /**
249
+ * The normalized names a `DROP INDEX` statement targets, or `null` when the
250
+ * statement's name list cannot be parsed (which fails closed at the call site).
251
+ *
252
+ * @param {string} tail Text starting at the matched `DROP`.
253
+ * @returns {string[]|null}
254
+ */
255
+ export function parseDroppedIndexNames(tail) {
256
+ const m = DROP_INDEX_LIST_RE.exec(tail);
257
+ if (!m) return null;
258
+ // Re-match identifiers rather than splitting on "," so a quoted name that
259
+ // itself contains a comma stays one identifier.
260
+ const names = m[1].match(new RegExp(IDENT_SOURCE, "g"));
261
+ if (!names || names.length === 0) return null;
262
+ return names.map(normalizeIndexName);
263
+ }
264
+
265
+ // `CREATE [UNIQUE] INDEX [CONCURRENTLY] [IF NOT EXISTS] <name>`.
266
+ const CREATE_INDEX_SOURCE =
267
+ `\\bCREATE\\s+(?:UNIQUE\\s+)?INDEX\\s+(?:CONCURRENTLY\\s+)?(?:IF\\s+NOT\\s+EXISTS\\s+)?(${IDENT_SOURCE})`;
268
+
269
+ /**
270
+ * Normalize an index identifier for comparison: unquote, drop any schema
271
+ * qualifier, and lowercase (SQL identifiers are case-insensitive unquoted).
272
+ *
273
+ * @param {string} raw
274
+ * @returns {string}
275
+ */
276
+ export function normalizeIndexName(raw) {
277
+ const unquoted = raw.replace(/["`[\]]/g, "");
278
+ const segments = unquoted.split(".");
279
+ return segments[segments.length - 1].toLowerCase();
280
+ }
281
+
282
+ // A postgres dollar-quote delimiter: `$$` or `$tag$`.
283
+ const DOLLAR_QUOTE_RE = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/;
284
+
285
+ /**
286
+ * Blank out the spans of `text` that cannot execute — multi-line `/* … *​/`
287
+ * block comments, single-quoted SQL string literals, and postgres
288
+ * dollar-quoted bodies (`$$ … $$` / `$tag$ … $tag$`, which is how a function
289
+ * body reaches the server as a literal) — preserving LENGTH so offsets stay
290
+ * comparable with the unmasked text.
291
+ *
292
+ * The two scans are deliberately asymmetric, and both directions fail closed:
293
+ * the DROP scan reads text with only per-line comments stripped (detect as much
294
+ * as possible), while the RECREATE scan reads this masked text (excuse as
295
+ * little as possible). Without it, a `CREATE INDEX idx_a …` that never runs —
296
+ * commented out as a rollback note, or quoted inside an INSERT — would excuse a
297
+ * real `DROP INDEX idx_a`, and the index would be gone at the end of the
298
+ * migration with no acknowledgement. An unterminated quote masks the remainder
299
+ * of the file, which withdraws excuses rather than granting them.
300
+ *
301
+ * @param {string} text
302
+ * @returns {string} Same length as `text`.
303
+ */
304
+ export function maskNonExecutable(text) {
305
+ const chars = text.split("");
306
+ const blank = (from, to) => {
307
+ for (let k = from; k < to; k++) if (chars[k] !== "\n") chars[k] = " ";
308
+ };
309
+ let i = 0;
310
+ while (i < text.length) {
311
+ if (text[i] === "/" && text[i + 1] === "*") {
312
+ const close = text.indexOf("*/", i + 2);
313
+ const end = close === -1 ? text.length : close + 2;
314
+ blank(i, end);
315
+ i = end;
316
+ continue;
317
+ }
318
+ // `#` is a MySQL line comment. It is masked on THIS side only: blanking it
319
+ // on the drop side would let `INSERT … '#'; DROP TABLE x;` hide a real drop.
320
+ if (text[i] === "#") {
321
+ const nl = text.indexOf("\n", i);
322
+ const end = nl === -1 ? text.length : nl;
323
+ blank(i, end);
324
+ i = end;
325
+ continue;
326
+ }
327
+ if (text[i] === "$") {
328
+ const tag = DOLLAR_QUOTE_RE.exec(text.slice(i))?.[0];
329
+ if (tag) {
330
+ const close = text.indexOf(tag, i + tag.length);
331
+ const end = close === -1 ? text.length : close + tag.length;
332
+ blank(i, end);
333
+ i = end;
334
+ continue;
335
+ }
336
+ i++;
337
+ continue;
338
+ }
339
+ if (text[i] === "'") {
340
+ let k = i + 1;
341
+ while (k < text.length) {
342
+ // '' is an escaped quote inside a SQL string literal, not a close.
343
+ if (text[k] === "'" && text[k + 1] === "'") {
344
+ k += 2;
345
+ continue;
346
+ }
347
+ if (text[k] === "'") {
348
+ k++;
349
+ break;
350
+ }
351
+ k++;
352
+ }
353
+ blank(i, k);
354
+ i = k;
355
+ continue;
356
+ }
357
+ i++;
358
+ }
359
+ return chars.join("");
360
+ }
361
+
362
+ /**
363
+ * Every index this text (re)creates, with the offset at which the CREATE
364
+ * appears. Offsets are what make the pairing order-sensitive: only a create
365
+ * that lands AFTER the drop leaves the index in place at the end of the
366
+ * migration.
367
+ *
368
+ * @param {string} text Comment-stripped migration text.
369
+ * @returns {Array<{name: string, index: number}>}
370
+ */
371
+ export function collectCreatedIndexes(text) {
372
+ const re = new RegExp(CREATE_INDEX_SOURCE, "gi");
373
+ const created = [];
374
+ let m;
375
+ while ((m = re.exec(text)) !== null) {
376
+ created.push({ name: normalizeIndexName(m[1]), index: m.index });
377
+ }
378
+ return created;
379
+ }
380
+
381
+ /**
382
+ * Scan a migration file's text for `DROP <object>` statements, pairing a
383
+ * `DROP INDEX` with a later recreate of the same index in the same text.
384
+ *
385
+ * Three texts, all the same length so their offsets are comparable:
386
+ * • `text` — raw; the only place the section directives survive.
387
+ * • `dropText` — comments masked; what the DROP scan reads (detect as much
388
+ * as possible; multi-line block residue left in on purpose).
389
+ * • `createText` — additionally non-executable spans masked; what the RECREATE
390
+ * scan reads (excuse as little as possible).
391
+ *
392
+ * @param {string} text Raw migration text.
393
+ * @returns {{ destructiveDrops: number, recreatedIndexes: string[] }}
394
+ * `destructiveDrops` counts the drops that still trip the guard;
395
+ * `recreatedIndexes` names the index drops excused as lossless.
396
+ */
397
+ export function scanDropStatements(text) {
398
+ const dropText = maskComments(text);
399
+ // Only an executable CREATE counts as a recreate — see maskNonExecutable.
400
+ const created = collectCreatedIndexes(maskNonExecutable(dropText));
401
+ // A recreate on the far side of a direction marker runs in the other
402
+ // direction — a Down-section CREATE cannot un-drop what Up dropped.
403
+ const boundaries = sectionBoundaries(text);
404
+ const sameSection = (dropAt, createAt) =>
405
+ !boundaries.some((b) => b > dropAt && b <= createAt);
406
+ const re = new RegExp(DROP_OBJECT_SOURCE, "gi");
407
+ let destructiveDrops = 0;
408
+ const recreatedIndexes = [];
409
+ let m;
410
+ while ((m = re.exec(dropText)) !== null) {
411
+ if (m[1].toUpperCase() === "INDEX") {
412
+ const names = parseDroppedIndexNames(dropText.slice(m.index));
413
+ // An unparseable name list fails closed — counted as destructive below.
414
+ // EVERY name in the list must be recreated after the drop; one excused
415
+ // name never excuses its neighbours.
416
+ if (
417
+ names &&
418
+ names.every((name) =>
419
+ created.some(
420
+ (c) => c.name === name && c.index > m.index && sameSection(m.index, c.index)
421
+ )
422
+ )
423
+ ) {
424
+ recreatedIndexes.push(...names);
425
+ continue;
426
+ }
427
+ }
428
+ destructiveDrops++;
429
+ }
430
+ return { destructiveDrops, recreatedIndexes };
431
+ }
432
+
169
433
  /**
170
434
  * Scan a single migration file's text for destructive signals.
171
435
  *
@@ -181,6 +445,8 @@ export function scanMigrationText(text) {
181
445
  if (re.test(line)) found.add(signal);
182
446
  }
183
447
  }
448
+ const { destructiveDrops } = scanDropStatements(text);
449
+ if (destructiveDrops > 0) found.add("DROP statement");
184
450
  return [...found];
185
451
  }
186
452
 
@@ -24,12 +24,19 @@ import { test } from "node:test";
24
24
  import {
25
25
  DEFAULT_MIGRATION_GLOBS,
26
26
  DEFAULT_OVERRIDE_LABEL,
27
+ collectCreatedIndexes,
27
28
  detectDestructiveMigrations,
28
29
  formatStepSummary,
29
30
  globToRegExp,
30
31
  isMigrationFile,
32
+ maskComments,
33
+ maskNonExecutable,
34
+ normalizeIndexName,
31
35
  parseArgs,
36
+ parseDroppedIndexNames,
37
+ scanDropStatements,
32
38
  scanMigrationText,
39
+ sectionBoundaries,
33
40
  stripComments,
34
41
  } from "./check-destructive-migration.mjs";
35
42
 
@@ -160,6 +167,294 @@ test("scanMigrationText: de-duplicates repeated signals", () => {
160
167
  assert.deepEqual(signals, ["DROP statement"]);
161
168
  });
162
169
 
170
+ // ── the recreated-index carve-out (Story #367) ─────────────────────────────
171
+ //
172
+ // Narrowing a partial index on SQLite is necessarily DROP-then-CREATE. Before
173
+ // #367 that lossless, everyday migration tripped a human-in-the-loop gate and
174
+ // needed an acknowledgement label, which is how operators learn to wave the
175
+ // check through. The carve-out is one shape and one shape only; the tests below
176
+ // pin both halves — what it excuses, and everything it must still stop.
177
+
178
+ test("a DROP INDEX recreated later in the same migration is lossless", () => {
179
+ const sql = [
180
+ "DROP INDEX idx_orders_open;",
181
+ "CREATE INDEX idx_orders_open ON orders (customer_id) WHERE status = 'open';",
182
+ ].join("\n");
183
+ assert.deepEqual(scanMigrationText(sql), []);
184
+ });
185
+
186
+ test("the recreate is matched on the index name, not merely on some CREATE INDEX", () => {
187
+ const sql = ["DROP INDEX idx_orders_open;", "CREATE INDEX idx_orders_closed ON orders (id);"].join(
188
+ "\n"
189
+ );
190
+ assert.deepEqual(scanMigrationText(sql), ["DROP statement"]);
191
+ });
192
+
193
+ test("a DROP INDEX with no recreate still trips the guard", () => {
194
+ assert.deepEqual(scanMigrationText("DROP INDEX idx_users_email;"), ["DROP statement"]);
195
+ });
196
+
197
+ test("a recreate BEFORE the drop is not a recreate — the index is gone at the end", () => {
198
+ const sql = ["CREATE INDEX idx_orders_open ON orders (id);", "DROP INDEX idx_orders_open;"].join(
199
+ "\n"
200
+ );
201
+ assert.deepEqual(scanMigrationText(sql), ["DROP statement"]);
202
+ });
203
+
204
+ test("the carve-out tolerates IF EXISTS / IF NOT EXISTS, UNIQUE, quoting and schema qualifiers", () => {
205
+ const sql = [
206
+ 'DROP INDEX IF EXISTS "public"."idx_orders_open";',
207
+ "CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_open ON orders (id) WHERE archived = 0;",
208
+ ].join("\n");
209
+ assert.deepEqual(scanMigrationText(sql), []);
210
+ });
211
+
212
+ test("a recreate in a DIFFERENT file does not excuse the drop", () => {
213
+ const files = {
214
+ "db/migrations/0012_drop_idx.sql": "DROP INDEX idx_orders_open;",
215
+ "db/migrations/0013_add_idx.sql": "CREATE INDEX idx_orders_open ON orders (id);",
216
+ };
217
+ const res = detectDestructiveMigrations({
218
+ changedFiles: Object.keys(files),
219
+ readFile: fakeReader(files),
220
+ });
221
+ assert.equal(res.destructive, true);
222
+ assert.equal(res.findings.length, 1);
223
+ assert.equal(res.findings[0].file, "db/migrations/0012_drop_idx.sql");
224
+ });
225
+
226
+ test("only the INDEX drop is excused — a table drop in the same file still blocks", () => {
227
+ const sql = [
228
+ "DROP INDEX idx_orders_open;",
229
+ "CREATE INDEX idx_orders_open ON orders (id);",
230
+ "DROP TABLE legacy_orders;",
231
+ ].join("\n");
232
+ assert.deepEqual(scanMigrationText(sql), ["DROP statement"]);
233
+ });
234
+
235
+ test("dropping a table, a column, a constraint, or truncating is untouched by the carve-out", () => {
236
+ // Each of these pairs the destructive statement with a CREATE INDEX that
237
+ // recreates an index name, proving the pairing cannot leak past DROP INDEX.
238
+ const recreate = "\nDROP INDEX idx_x;\nCREATE INDEX idx_x ON t (a);";
239
+ assert.deepEqual(scanMigrationText(`DROP TABLE users;${recreate}`), ["DROP statement"]);
240
+ assert.deepEqual(scanMigrationText(`DROP COLUMN email;${recreate}`), ["DROP statement"]);
241
+ assert.deepEqual(scanMigrationText(`ALTER TABLE users DROP CONSTRAINT fk_org;${recreate}`), [
242
+ "ALTER TABLE … DROP",
243
+ "DROP statement",
244
+ ]);
245
+ assert.deepEqual(scanMigrationText(`ALTER TABLE users DROP COLUMN legacy_id;${recreate}`), [
246
+ "ALTER TABLE … DROP",
247
+ "DROP statement",
248
+ ]);
249
+ assert.deepEqual(scanMigrationText(`TRUNCATE audit_log;${recreate}`), ["TRUNCATE"]);
250
+ assert.deepEqual(scanMigrationText(`table.dropIndex('idx_x');${recreate}`), [
251
+ "drizzle destructive op",
252
+ ]);
253
+ });
254
+
255
+ test("a comma-separated drop list is excused only when EVERY name is recreated", () => {
256
+ // `DROP INDEX a, b;` is valid postgres. Pairing on the first name alone would
257
+ // let idx_b be dropped with no recreate and no acknowledgement.
258
+ const partial = ["DROP INDEX idx_a, idx_b;", "CREATE INDEX idx_a ON t (x);"].join("\n");
259
+ assert.deepEqual(scanMigrationText(partial), ["DROP statement"]);
260
+
261
+ const reversed = ["DROP INDEX idx_b, idx_a;", "CREATE INDEX idx_a ON t (x);"].join("\n");
262
+ assert.deepEqual(scanMigrationText(reversed), ["DROP statement"]);
263
+
264
+ const whole = [
265
+ "DROP INDEX IF EXISTS idx_a, idx_b;",
266
+ "CREATE INDEX idx_a ON t (x);",
267
+ "CREATE INDEX idx_b ON t (y);",
268
+ ].join("\n");
269
+ assert.deepEqual(scanMigrationText(whole), []);
270
+ });
271
+
272
+ test("parseDroppedIndexNames reads the whole list, or nothing", () => {
273
+ assert.deepEqual(parseDroppedIndexNames("DROP INDEX idx_a"), ["idx_a"]);
274
+ assert.deepEqual(parseDroppedIndexNames("DROP INDEX CONCURRENTLY idx_a , idx_b;"), [
275
+ "idx_a",
276
+ "idx_b",
277
+ ]);
278
+ assert.deepEqual(parseDroppedIndexNames('DROP INDEX IF EXISTS "public"."idx_a", idx_b'), [
279
+ "idx_a",
280
+ "idx_b",
281
+ ]);
282
+ // MySQL's `DROP INDEX i ON t` — the table is not part of the name list.
283
+ assert.deepEqual(parseDroppedIndexNames("DROP INDEX idx_a ON orders"), ["idx_a"]);
284
+ assert.equal(parseDroppedIndexNames("DROP INDEX ;"), null);
285
+ assert.equal(parseDroppedIndexNames("DROP TABLE users"), null);
286
+ });
287
+
288
+ test("a CREATE INDEX that never executes does not excuse a real drop", () => {
289
+ // Both of these leave the index gone at the end of the migration. Only an
290
+ // executable CREATE counts as a recreate.
291
+ const inBlockComment = [
292
+ "DROP INDEX idx_a;",
293
+ "/* rollback:",
294
+ "CREATE INDEX idx_a ON t (x);",
295
+ "*/",
296
+ ].join("\n");
297
+ assert.deepEqual(scanMigrationText(inBlockComment), ["DROP statement"]);
298
+
299
+ const inStringLiteral = [
300
+ "DROP INDEX idx_a;",
301
+ "INSERT INTO audit (msg) VALUES ('CREATE INDEX idx_a ON t (x)');",
302
+ ].join("\n");
303
+ assert.deepEqual(scanMigrationText(inStringLiteral), ["DROP statement"]);
304
+
305
+ const inLineComment = ["DROP INDEX idx_a;", "-- CREATE INDEX idx_a ON t (x);"].join("\n");
306
+ assert.deepEqual(scanMigrationText(inLineComment), ["DROP statement"]);
307
+ });
308
+
309
+ test("masking preserves offsets so a real recreate after a masked span still pairs", () => {
310
+ const sql = [
311
+ "DROP INDEX idx_a;",
312
+ "/* the index below replaces it */",
313
+ "INSERT INTO audit (msg) VALUES ('dropping idx_a');",
314
+ "CREATE INDEX idx_a ON t (x) WHERE archived = 0;",
315
+ ].join("\n");
316
+ assert.deepEqual(scanMigrationText(sql), []);
317
+ });
318
+
319
+ test("maskNonExecutable blanks non-executable spans without moving anything", () => {
320
+ const text = "a /* b\nc */ d 'e f' g";
321
+ const masked = maskNonExecutable(text);
322
+ assert.equal(masked.length, text.length);
323
+ assert.equal(masked.indexOf("d"), text.indexOf("d"), "offsets must be preserved");
324
+ assert.ok(!masked.includes("b") && !masked.includes("c"), "block comment blanked");
325
+ assert.ok(!masked.includes("e") && !masked.includes("f"), "string literal blanked");
326
+ assert.ok(masked.includes("a") && masked.includes("g"), "executable text survives");
327
+ assert.equal((masked.match(/\n/g) ?? []).length, 1, "newlines survive");
328
+ });
329
+
330
+ test("a CREATE INDEX inside a dollar-quoted body does not excuse a drop", () => {
331
+ // A postgres function body reaches the server as a string literal — it does
332
+ // not run at migration time, so idx_a really is gone.
333
+ const sql = [
334
+ "DROP INDEX idx_a;",
335
+ "CREATE FUNCTION rebuild() RETURNS void AS $$",
336
+ "BEGIN CREATE INDEX idx_a ON t (x); END;",
337
+ "$$ LANGUAGE plpgsql;",
338
+ ].join("\n");
339
+ assert.deepEqual(scanMigrationText(sql), ["DROP statement"]);
340
+
341
+ const tagged = [
342
+ "DROP INDEX idx_a;",
343
+ "CREATE FUNCTION rebuild() RETURNS void AS $body$",
344
+ "BEGIN CREATE INDEX idx_a ON t (x); END;",
345
+ "$body$ LANGUAGE plpgsql;",
346
+ ].join("\n");
347
+ assert.deepEqual(scanMigrationText(tagged), ["DROP statement"]);
348
+ });
349
+
350
+ test("a CREATE INDEX in a `#` MySQL line comment does not excuse a drop", () => {
351
+ const sql = ["DROP INDEX idx_a;", "# CREATE INDEX idx_a ON t (x);"].join("\n");
352
+ assert.deepEqual(scanMigrationText(sql), ["DROP statement"]);
353
+ });
354
+
355
+ test("a `#` on the drop side is not treated as a comment", () => {
356
+ // Masking `#` for the DROP scan too would let a literal containing one hide
357
+ // everything after it on the line.
358
+ assert.deepEqual(scanMigrationText("INSERT INTO t (c) VALUES ('#'); DROP TABLE users;"), [
359
+ "DROP statement",
360
+ ]);
361
+ });
362
+
363
+ test("a recreate in the Down section does not excuse a drop in the Up section", () => {
364
+ // goose, sql-migrate and dbmate all run one direction at a time: the Down
365
+ // CREATE INDEX does not run when Up drops the index.
366
+ const goose = [
367
+ "-- +goose Up",
368
+ "DROP INDEX idx_a;",
369
+ "-- +goose Down",
370
+ "CREATE INDEX idx_a ON t (x);",
371
+ ].join("\n");
372
+ assert.deepEqual(scanMigrationText(goose), ["DROP statement"]);
373
+
374
+ const dbmate = [
375
+ "-- migrate:up",
376
+ "DROP INDEX idx_a;",
377
+ "-- migrate:down",
378
+ "CREATE INDEX idx_a ON t (x);",
379
+ ].join("\n");
380
+ assert.deepEqual(scanMigrationText(dbmate), ["DROP statement"]);
381
+
382
+ const sqlMigrate = [
383
+ "-- +migrate Up",
384
+ "DROP INDEX idx_a;",
385
+ "-- +migrate Down",
386
+ "CREATE INDEX idx_a ON t (x);",
387
+ ].join("\n");
388
+ assert.deepEqual(scanMigrationText(sqlMigrate), ["DROP statement"]);
389
+ });
390
+
391
+ test("a bidirectional migration that narrows an index in BOTH directions passes", () => {
392
+ // Each section pairs within itself — the section rule must not block the
393
+ // legitimate case it exists to keep honest.
394
+ const sql = [
395
+ "-- +goose Up",
396
+ "DROP INDEX idx_a;",
397
+ "CREATE INDEX idx_a ON t (x) WHERE archived = 0;",
398
+ "-- +goose Down",
399
+ "DROP INDEX idx_a;",
400
+ "CREATE INDEX idx_a ON t (x);",
401
+ ].join("\n");
402
+ assert.deepEqual(scanMigrationText(sql), []);
403
+ });
404
+
405
+ test("sectionBoundaries finds the direction markers in the raw text", () => {
406
+ const sql = ["-- +goose Up", "SELECT 1;", "-- +goose Down", "SELECT 2;"].join("\n");
407
+ const offsets = sectionBoundaries(sql);
408
+ assert.equal(offsets.length, 2);
409
+ assert.equal(offsets[0], 0);
410
+ assert.equal(offsets[1], sql.indexOf("-- +goose Down"));
411
+ assert.deepEqual(sectionBoundaries("DROP INDEX idx_a;"), []);
412
+ });
413
+
414
+ test("maskComments preserves length and blanks the same syntaxes stripComments does", () => {
415
+ const text = "keep -- gone\nkeep2 // gone\na /* gone */ b";
416
+ const masked = maskComments(text);
417
+ assert.equal(masked.length, text.length);
418
+ assert.ok(!masked.includes("gone"));
419
+ assert.ok(masked.includes("keep") && masked.includes("keep2") && masked.includes("b"));
420
+ });
421
+
422
+ test("a `$` inside an identifier is not a dollar quote", () => {
423
+ // Masking from a bare `$` to the next one would swallow a real recreate.
424
+ const sql = ["DROP INDEX idx$a;", "CREATE INDEX idx$a ON t (x);"].join("\n");
425
+ assert.deepEqual(scanMigrationText(sql), []);
426
+ });
427
+
428
+ test("maskNonExecutable: an escaped '' does not close the literal early", () => {
429
+ const masked = maskNonExecutable("x 'it''s CREATE INDEX idx_a' y");
430
+ assert.ok(!masked.includes("CREATE"));
431
+ assert.ok(masked.includes("x") && masked.includes("y"));
432
+ });
433
+
434
+ test("scanDropStatements reports which index drops were excused", () => {
435
+ const res = scanDropStatements(
436
+ "DROP INDEX idx_a;\nCREATE INDEX idx_a ON t (x);\nDROP TABLE t2;"
437
+ );
438
+ assert.equal(res.destructiveDrops, 1);
439
+ assert.deepEqual(res.recreatedIndexes, ["idx_a"]);
440
+ });
441
+
442
+ test("normalizeIndexName unquotes, de-qualifies and lowercases", () => {
443
+ assert.equal(normalizeIndexName('"public"."IDX_A"'), "idx_a");
444
+ assert.equal(normalizeIndexName("public.IDX_A"), "idx_a");
445
+ assert.equal(normalizeIndexName("`idx_a`"), "idx_a");
446
+ assert.equal(normalizeIndexName("[idx_a]"), "idx_a");
447
+ });
448
+
449
+ test("collectCreatedIndexes records every create with its offset", () => {
450
+ const created = collectCreatedIndexes("CREATE INDEX a ON t (x);\nCREATE UNIQUE INDEX b ON t (y);");
451
+ assert.deepEqual(
452
+ created.map((c) => c.name),
453
+ ["a", "b"]
454
+ );
455
+ assert.ok(created[1].index > created[0].index);
456
+ });
457
+
163
458
  // ── detectDestructiveMigrations: end-to-end over a changed set ─────────────
164
459
 
165
460
  test("detect: flags a destructive migration file", () => {
@@ -340,6 +635,45 @@ test("CLI: comment-only DROP does not trip the guard (exit 0)", () => {
340
635
  }
341
636
  });
342
637
 
638
+ test("CLI: a narrowed partial index passes with NO acknowledgement label (exit 0)", () => {
639
+ // The end-to-end shape of the false positive #367 removes: on SQLite this is
640
+ // the only way to narrow a partial index, and it was demanding a label.
641
+ const root = mkdtempSync(join(tmpdir(), "destmig-"));
642
+ try {
643
+ mkdirSync(join(root, "db", "migrations"), { recursive: true });
644
+ writeFileSync(
645
+ join(root, "db", "migrations", "0012_narrow_idx.sql"),
646
+ "DROP INDEX idx_orders_open;\n" +
647
+ "CREATE INDEX idx_orders_open ON orders (customer_id) WHERE status = 'open';\n"
648
+ );
649
+ const res = runCli(["--changed-files", "-", "--repo-root", root], {
650
+ input: "db/migrations/0012_narrow_idx.sql\n",
651
+ });
652
+ assert.equal(res.code, 0);
653
+ assert.ok(res.stdout.includes("No destructive migration detected"));
654
+ } finally {
655
+ rmSync(root, { recursive: true, force: true });
656
+ }
657
+ });
658
+
659
+ test("CLI: dropping an index WITHOUT recreating it still blocks (exit 1)", () => {
660
+ const root = mkdtempSync(join(tmpdir(), "destmig-"));
661
+ try {
662
+ mkdirSync(join(root, "db", "migrations"), { recursive: true });
663
+ writeFileSync(
664
+ join(root, "db", "migrations", "0013_drop_idx.sql"),
665
+ "DROP INDEX idx_orders_open;\n"
666
+ );
667
+ const res = runCli(["--changed-files", "-", "--repo-root", root], {
668
+ input: "db/migrations/0013_drop_idx.sql\n",
669
+ });
670
+ assert.equal(res.code, 1);
671
+ assert.ok(res.stderr.includes("DROP statement"));
672
+ } finally {
673
+ rmSync(root, { recursive: true, force: true });
674
+ }
675
+ });
676
+
343
677
  test("CLI: missing --changed-files is a usage error (exit 2)", () => {
344
678
  const res = runCli([]);
345
679
  assert.equal(res.code, 2);