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.
- package/README.md +69 -12
- package/config/stryker.base.json +7 -2
- package/package.json +1 -1
- package/scripts/audit-check.mjs +331 -6
- package/scripts/audit-check.test.mjs +382 -1
- package/scripts/check-action-pins.mjs +87 -15
- package/scripts/check-action-pins.test.mjs +103 -4
- package/scripts/check-affected-mode.test.mjs +5 -51
- package/scripts/check-codeql-gating.test.mjs +649 -0
- package/scripts/check-destructive-migration.mjs +277 -11
- package/scripts/check-destructive-migration.test.mjs +334 -0
- package/scripts/check-environments-isolation-audit.test.mjs +212 -0
- package/scripts/check-fail-fast-attribution.test.mjs +90 -4
- package/scripts/check-first-party-pin-freshness.mjs +648 -0
- package/scripts/check-first-party-pin-freshness.test.mjs +624 -0
- package/scripts/check-gitleaks-allowlist.test.mjs +312 -0
- package/scripts/check-osv-scan-mode.test.mjs +5 -50
- package/scripts/check-release-type.mjs +591 -0
- package/scripts/check-release-type.test.mjs +678 -0
- package/scripts/check-setup-toolchain-store.test.mjs +139 -0
- package/scripts/check-toolchain-cache-default.test.mjs +308 -0
- package/scripts/lib/yaml-step.mjs +109 -0
- package/scripts/lib/yaml-step.test.mjs +156 -0
- package/scripts/osv-report-gate.test.mjs +289 -0
- package/scripts/runner-env-drift.test.mjs +554 -0
- package/scripts/stryker-base-config.test.mjs +256 -0
- package/templates/runbooks/runner-provisioning.md +50 -6
- package/templates/runner/check-runner-env-drift.sh +248 -0
|
@@ -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.
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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);
|