tewip-ai 0.39.0 → 0.40.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 CHANGED
@@ -836,7 +836,8 @@ That last section is the part no SQL linter does: it searches your own code for
836
836
  away. "This drops `users.nickname`, and four files still read it" is a different sentence from "dropping a
837
837
  column is risky", and it is the one that stops the outage.
838
838
 
839
- It reads **SQL, Django, Rails and Alembic** migrations, judges **only the forward path** (a rollback step
839
+ It reads **SQL, Django, Rails, Alembic and TypeORM** migrations — which covers Prisma, Drizzle, Flyway
840
+ and golang-migrate too, since those all emit plain `.sql`. It judges **only the forward path** (a rollback step
840
841
  contains the mirror image of the change, so reading it says a migration that creates a table destroys
841
842
  one), and stays quiet when the team already did the safe thing — `atomic = False`, `algorithm:
842
843
  :concurrently`, `NOT VALID`, `postgresql_concurrently=True`. When it cannot read a migration it says so
@@ -11,6 +11,12 @@ export function dialectOf(file, text) {
11
11
  return 'rails';
12
12
  if (/\bop\.(?:drop_column|add_column|create_index|alter_column|drop_table|rename_table|execute)\b/.test(text))
13
13
  return 'alembic';
14
+ // TypeORM writes a migration as a class with up() and down(), and puts the real DDL in template
15
+ // strings passed to queryRunner.query(). The SQL is right there and perfectly readable; only the
16
+ // wrapper was unfamiliar, so every TypeORM project was told Tewip could not read its migrations
17
+ // at all - and on a Node backend that is most of them.
18
+ if (/\bMigrationInterface\b|\bqueryRunner\.query\s*\(/.test(text))
19
+ return 'typeorm';
14
20
  if (/(^|\/)(migrations?|db\/migrate)\//.test(path))
15
21
  return 'unknown';
16
22
  return 'unknown';
@@ -157,7 +163,12 @@ const ALEMBIC = [
157
163
  table: (m) => m[1],
158
164
  },
159
165
  ];
160
- const BY_DIALECT = { django: DJANGO, rails: RAILS, alembic: ALEMBIC };
166
+ // TypeORM has no operations of its own: unlike Django's `RemoveField` or Rails's `remove_column`,
167
+ // a TypeORM migration contains SQL and nothing else, handed to queryRunner.query(). So its list is
168
+ // empty on purpose and every finding comes from reading that SQL, which is the same reader the
169
+ // `.sql` dialect uses. The exhaustive Record is what made this a compile error rather than a
170
+ // TypeORM migration silently producing no findings at all.
171
+ const BY_DIALECT = { django: DJANGO, rails: RAILS, alembic: ALEMBIC, typeorm: [] };
161
172
  /** Findings from a framework migration, with the line each one sits on. */
162
173
  export function readFramework(dialect, text) {
163
174
  const lines = text.split(String.fromCharCode(10));
@@ -216,7 +227,10 @@ export function forwardOnly(dialect, text) {
216
227
  const line = lines[i];
217
228
  const down = (dialect === 'rails' && /^\s*def\s+down\b/.test(line)) ||
218
229
  (dialect === 'alembic' && /^\s*def\s+downgrade\s*\(/.test(line)) ||
219
- (dialect === 'sql' && /^\s*--\s*(?:\+migrate\s+Down|\+goose\s+Down|\/\/@UNDO|Down\b)/i.test(line));
230
+ (dialect === 'sql' && /^\s*--\s*(?:\+migrate\s+Down|\+goose\s+Down|\/\/@UNDO|Down\b)/i.test(line)) ||
231
+ // TypeORM's rollback is `public async down(queryRunner: QueryRunner)`. Reading it as part of
232
+ // the change would report a migration that adds a column as one that drops it.
233
+ (dialect === 'typeorm' && /^\s*(?:public\s+)?async\s+down\s*\(/.test(line));
220
234
  if (down) {
221
235
  // To the end of the block: the next line at the same indentation or less that starts something new.
222
236
  blankFrom(i, (l, indent) => l.trim() !== '' && indentOf(l) <= indent && !/^\s*(end|\})\s*$/.test(l), indentOf(line));
@@ -17,10 +17,39 @@ const MIGRATION = /(^|\/)(migrations?|db\/migrate|prisma\/migrations|alembic|fly
17
17
  const SCHEMA_FILE = /\.(sql)$|(^|\/)(migrations?|db\/migrate)\/.*\.(py|rb|js|ts)$/i;
18
18
  /** The schema files in this change. */
19
19
  export function schemaFiles(cmd) {
20
- const given = cmd.files && cmd.files.length > 0 ? cmd.files : (changeSummary(cmd.root, cmd.diff)?.files ?? []);
20
+ const named = cmd.files !== undefined && cmd.files.length > 0;
21
+ const given = named ? cmd.files : (changeSummary(cmd.root, cmd.diff)?.files ?? []);
21
22
  return given
22
23
  .map((f) => f.split('\\').join('/'))
23
- .filter((f) => (SCHEMA_FILE.test(f) || MIGRATION.test(f)) && existsSync(resolve(cmd.root, f)));
24
+ // Asking for a file by name overrides the path filter: the filter exists to find migrations in
25
+ // a diff of a hundred files, not to argue with somebody who pointed at one. A TypeORM migration
26
+ // sitting outside a migrations/ directory was being answered with "no migration in this change".
27
+ .filter((f) => (named || SCHEMA_FILE.test(f) || MIGRATION.test(f)) && existsSync(resolve(cmd.root, f)));
28
+ }
29
+ /**
30
+ * Tables this migration creates itself.
31
+ *
32
+ * A lock is only expensive because of the rows it holds up, and a table created three statements
33
+ * earlier has none. This matters enormously on the tools that generate their SQL: a Prisma project
34
+ * emits `CREATE TABLE` followed by an `ADD CONSTRAINT ... FOREIGN KEY` for every relation, so
35
+ * documenso's 165 real migrations produced 275 "blocks writes while it runs" findings - 1.7 per
36
+ * migration, nearly all of them about tables that did not exist a moment before. That is the volume
37
+ * at which a team stops reading the report, which costs more than the warnings are worth.
38
+ */
39
+ function createdHere(text) {
40
+ const names = new Set();
41
+ for (const m of text.matchAll(/\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?"?([\w$.]+)"?/gi)) {
42
+ names.add(m[1].toLowerCase().replace(/^.*\./, ''));
43
+ }
44
+ return names;
45
+ }
46
+ /** Risks that are only risks because rows already exist. */
47
+ const ONLY_HURTS_EXISTING_ROWS = new Set(['locks-writes', 'slow']);
48
+ export function droppingWhatIsNew(findings, text) {
49
+ const fresh = createdHere(text);
50
+ if (fresh.size === 0)
51
+ return findings;
52
+ return findings.filter((f) => !(ONLY_HURTS_EXISTING_ROWS.has(f.risk) && fresh.has(f.table.toLowerCase().replace(/^.*\./, ''))));
24
53
  }
25
54
  export function readMigration(root, file) {
26
55
  let text = '';
@@ -35,13 +64,13 @@ export function readMigration(root, file) {
35
64
  // part of the change says a migration that creates a table destroys one.
36
65
  const forward = forwardOnly(dialect, text);
37
66
  if (dialect === 'sql')
38
- return { file, dialect, findings: readSql(forward), unread: '', irreversible: false };
67
+ return { file, dialect, findings: droppingWhatIsNew(readSql(forward), forward), unread: '', irreversible: false };
39
68
  if (dialect === 'unknown') {
40
69
  return {
41
70
  file,
42
71
  dialect,
43
72
  findings: [],
44
- unread: 'Tewip does not recognise how this migration is written, so it says nothing about it rather than passing it. It reads SQL, Django, Rails and Alembic migrations',
73
+ unread: 'Tewip does not recognise how this migration is written, so it says nothing about it rather than passing it. It reads SQL, Django, Rails, Alembic and TypeORM migrations',
45
74
  irreversible: false,
46
75
  };
47
76
  }
@@ -49,10 +78,12 @@ export function readMigration(root, file) {
49
78
  // A migration that only runs raw SQL through the framework is still readable: the SQL is the migration.
50
79
  // Raw SQL, however it reaches the database: through the framework, or through a cursor inside a data
51
80
  // migration, which is where most large projects put the dangerous parts.
52
- const raw = [...forward.matchAll(/(?:RunSQL|op\.execute|execute|cursor\.execute)\(\s*(?:r?["'`]{1,3})([\s\S]*?)["'`]{1,3}\s*[,)]/g)].map((m) => m[1]).join(';');
81
+ // `queryRunner.query` is TypeORM's, and is the whole of a TypeORM migration rather than an
82
+ // escape hatch inside one: the class is a wrapper and every statement arrives this way.
83
+ const raw = [...forward.matchAll(/(?:RunSQL|op\.execute|queryRunner\.query|execute|cursor\.execute)\(\s*(?:r?["'`]{1,3})([\s\S]*?)["'`]{1,3}\s*[,)]/g)].map((m) => m[1]).join(';');
53
84
  if (raw.trim())
54
85
  findings.push(...readSql(raw));
55
- return { file, dialect, findings, unread: '', irreversible: irreversible(dialect, text) };
86
+ return { file, dialect, findings: droppingWhatIsNew(findings, forward), unread: '', irreversible: irreversible(dialect, text) };
56
87
  }
57
88
  /**
58
89
  * Code outside the migrations that still names a table or column this change takes away.
@@ -20,7 +20,12 @@ const RULES = [
20
20
  table: (m) => bare(m[1]),
21
21
  },
22
22
  {
23
- find: new RegExp(String.raw `\bALTER\s+TABLE\s+(${NAME})[\s\S]*?\bDROP\s+(?:COLUMN\s+)?(?:IF\s+EXISTS\s+)?(${NAME})`, 'i'),
23
+ // The word COLUMN is optional in SQL, so the name after DROP has to be checked against the
24
+ // other things an ALTER TABLE can drop. Without the lookahead, Prisma's own
25
+ // `ALTER TABLE "Share" DROP CONSTRAINT "Share_userId_fkey"` - which every Prisma project is full
26
+ // of - was read as dropping a column called CONSTRAINT, and reported as breaking the running
27
+ // version, which dropping a foreign key does not do. Found on documenso's real migrations.
28
+ find: new RegExp(String.raw `\bALTER\s+TABLE\s+(${NAME})[\s\S]*?\bDROP\s+(?:COLUMN\s+)?(?:IF\s+EXISTS\s+)?(?!CONSTRAINT\b|INDEX\b|DEFAULT\b|NOT\s+NULL\b|PRIMARY\b|FOREIGN\b|CHECK\b|UNIQUE\b|PARTITION\b)(${NAME})`, 'i'),
24
29
  risk: 'breaks-old-code',
25
30
  what: (m) => `drops the column ${bare(m[2])} from ${bare(m[1])}`,
26
31
  instead: 'deploy code that no longer selects or writes the column, then drop it in a later migration. During a rolling deploy the old version is still running and will fail on a column that is gone',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "mcpName": "io.github.hmamut39/tewip",
5
5
  "description": "Tewip finds why each failing test failed - real bug, flaky test, environment, test data or changed selector - for any stack, from the command line, CI, or your editor.",
6
6
  "keywords": [