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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": [
|