File size: 7,140 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
#!/usr/bin/env node
// scripts/check/check-migration-numbering.mjs
// Gate de numeração de migrations: protege src/lib/db/migrations/ contra regressões
// de numeração. WHY: um incidente destrutivo aconteceu DUAS VEZES — um `git rm` de
// uma migration duplicada durante um merge apagou uma migration REAL da release
// (094/095 em #3365/#3371). Este gate cria uma ligação de CI entre o disco e as
// anomalias já reconhecidas em migrationRunner.ts, falhando em QUALQUER:
//   - nome de arquivo sem prefixo numérico zero-padded (NNN_*.sql);
//   - prefixo de versão DUPLICADO no disco (exceto duplicatas já reconhecidas);
//   - NOVO gap inexplicado na sequência (gaps conhecidos 026/055 são congelados).
// As anomalias conhecidas são derivadas das listas de migrationRunner.ts
// (LEGACY_VERSION_SLOT_MIGRATIONS / SUPERSEDED_DUPLICATE_MIGRATIONS) + a auditoria
// de gaps de sequência. NÃO adicione novos itens sem justificativa — esse é o ponto.
// Stale-enforcement (6A.3): entrada em KNOWN_GAPS / KNOWN_DUPLICATE_VERSIONS que não
// suprime nenhuma anomalia real → gate falha com instrução de remoção.
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { assertNoStale } from "./lib/allowlist.mjs";

const cwd = process.cwd();
const MIGRATIONS_DIR = path.join(cwd, "src/lib/db/migrations");

// Convenção de nome: NNN_descricao.sql (prefixo numérico zero-padded de >= 3 dígitos).
// Mesmo regex usado pelo runner de produção (migrationRunner.ts ~linha 282).
const MIGRATION_NAME_RE = /^(\d{3,})_(.+)\.sql$/;

// ---------------------------------------------------------------------------
// ALLOWLIST 1 — duplicatas de versão CONHECIDAS.
// Fonte: src/lib/db/migrationRunner.ts → SUPERSEDED_DUPLICATE_MIGRATIONS (~L188).
// O runner já aceita estes slots de versão reutilizados (a migration "renomeada"
// foi promovida para um número novo, e o slot antigo é tolerado). Adicionar aqui
// SOMENTE se houver um arquivo físico duplicado no disco (stale-enforcement 6A.3
// detecta entradas sem duplicata física viva e força remoção).
// ---------------------------------------------------------------------------
export const KNOWN_DUPLICATE_VERSIONS = new Set([
  // "041" was removed: 041_session_account_affinity.sql no longer exists on disk
  // (only 041_compression_receipts.sql remains), so no physical duplicate is present.
  // The SUPERSEDED_DUPLICATE_MIGRATIONS entry in migrationRunner.ts handles the runner
  // compatibility at runtime without needing an allowlist here. (#6A.3 stale cleanup)
]);

// ---------------------------------------------------------------------------
// ALLOWLIST 2 — gaps de sequência CONHECIDOS.
// Fonte: auditoria do disco (src/lib/db/migrations/) — a sequência pula 026 e 055.
// Estes números nunca tiveram arquivo físico (slots legados que viraram outros
// números via RENAMED_MIGRATION_COMPATIBILITY em migrationRunner.ts). Congelados
// para que o gate bloqueie apenas NOVOS buracos inexplicados na sequência.
// ---------------------------------------------------------------------------
export const KNOWN_GAPS = new Set(["026", "055"]);

function pad3(n) {
  return String(n).padStart(3, "0");
}

/**
 * Função pura — detecta anomalias de numeração de migrations.
 *
 * @param {string[]} filenames    nomes de arquivo (basename) em src/lib/db/migrations/
 * @param {Set<string>} knownDuplicates  versões com duplicata reconhecida (ex.: "041")
 * @param {Set<string>} knownGaps        gaps de sequência reconhecidos (ex.: "026")
 * @returns {{ duplicates: Array<{version:string,names:string[]}>, gaps: string[], badNames: string[] }}
 */
export function findMigrationAnomalies(filenames, knownDuplicates, knownGaps) {
  const dups = knownDuplicates || new Set();
  const gapsAllow = knownGaps || new Set();

  const badNames = [];
  const byVersion = new Map();

  for (const filename of filenames) {
    if (!filename.endsWith(".sql")) continue;
    const match = filename.match(MIGRATION_NAME_RE);
    if (!match) {
      badNames.push(filename);
      continue;
    }
    const version = match[1];
    if (!byVersion.has(version)) byVersion.set(version, []);
    byVersion.get(version).push(filename);
  }

  // Duplicatas: dois arquivos físicos com o mesmo prefixo, exceto os reconhecidos.
  const duplicates = [];
  for (const [version, names] of byVersion.entries()) {
    if (names.length <= 1) continue;
    if (dups.has(version)) continue;
    duplicates.push({ version, names: [...names].sort() });
  }
  duplicates.sort((a, b) => a.version.localeCompare(b.version));

  // Gaps: buracos na sequência min..max que não estão na allowlist.
  const versions = [...byVersion.keys()].map((v) => parseInt(v, 10)).sort((a, b) => a - b);
  const gaps = [];
  if (versions.length > 0) {
    const min = versions[0];
    const max = versions[versions.length - 1];
    const present = new Set(versions);
    for (let n = min + 1; n < max; n++) {
      if (present.has(n)) continue;
      const padded = pad3(n);
      if (gapsAllow.has(padded)) continue;
      gaps.push(padded);
    }
  }

  return { duplicates, gaps, badNames };
}

function listMigrationFilenames() {
  if (!fs.existsSync(MIGRATIONS_DIR)) return [];
  return fs.readdirSync(MIGRATIONS_DIR).filter((f) => f.endsWith(".sql"));
}

function main() {
  const filenames = listMigrationFilenames();

  // Compute raw anomalies WITHOUT allowlists — needed for stale-enforcement (6A.3).
  const raw = findMigrationAnomalies(filenames, new Set(), new Set());
  const liveGaps = raw.gaps;
  const liveDupVersions = raw.duplicates.map((d) => d.version);
  assertNoStale(KNOWN_GAPS, liveGaps, "check-migration-numbering:gaps");
  assertNoStale(KNOWN_DUPLICATE_VERSIONS, liveDupVersions, "check-migration-numbering:duplicates");

  const { duplicates, gaps, badNames } = findMigrationAnomalies(
    filenames,
    KNOWN_DUPLICATE_VERSIONS,
    KNOWN_GAPS
  );

  const problems = [];
  for (const b of badNames) {
    problems.push(`  ✗ nome inválido (esperado NNN_descricao.sql): ${b}`);
  }
  for (const d of duplicates) {
    problems.push(`  ✗ prefixo de versão duplicado ${d.version}: [${d.names.join(", ")}]`);
  }
  for (const g of gaps) {
    problems.push(`  ✗ gap inexplicado na sequência: faltando ${g}`);
  }

  if (problems.length > 0) {
    console.error(
      `[check-migration-numbering] ${problems.length} anomalia(s) de numeração:\n` +
        problems.join("\n") +
        `\n  → renomeie o arquivo colidente, preencha o gap, ou — se for legítimo — ` +
        `adicione o número às allowlists KNOWN_DUPLICATE_VERSIONS / KNOWN_GAPS com ` +
        `justificativa rastreável a src/lib/db/migrationRunner.ts.`
    );
    process.exitCode = 1;
  }

  if (!process.exitCode) {
    console.log(
      `[check-migration-numbering] OK (${filenames.length} migrations, ` +
        `${KNOWN_GAPS.size} gap(s) conhecido(s), ${KNOWN_DUPLICATE_VERSIONS.size} duplicata(s) conhecida(s))`
    );
  }
}

if (import.meta.url === pathToFileURL(process.argv[1] || "").href) main();