tewip-ai 0.41.0 → 0.42.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 +1 -1
- package/dist/cli/help.js +5 -3
- package/dist/engine/a2a/card.js +1 -1
- package/dist/engine/schema/dialect-rules-node.js +190 -0
- package/dist/engine/schema/dialect-rules.js +147 -0
- package/dist/engine/schema/frameworks.js +36 -150
- package/dist/engine/schema/schema-command.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -870,7 +870,7 @@ That last section is the part no SQL linter does: it searches your own code for
|
|
|
870
870
|
away. "This drops `users.nickname`, and four files still read it" is a different sentence from "dropping a
|
|
871
871
|
column is risky", and it is the one that stops the outage.
|
|
872
872
|
|
|
873
|
-
It reads **SQL, Django, Rails, Alembic and
|
|
873
|
+
It reads **SQL, Django, Rails, Alembic, TypeORM, Sequelize, Knex, EF Core and Liquibase** migrations — which covers Prisma, Drizzle, Flyway
|
|
874
874
|
and golang-migrate too, since those all emit plain `.sql`. It judges **only the forward path** (a rollback step
|
|
875
875
|
contains the mirror image of the change, so reading it says a migration that creates a table destroys
|
|
876
876
|
one), and stays quiet when the team already did the safe thing — `atomic = False`, `algorithm:
|
package/dist/cli/help.js
CHANGED
|
@@ -157,9 +157,11 @@ Usage:
|
|
|
157
157
|
What will this migration do to a live database? Names the changes
|
|
158
158
|
that break the version still running during a deploy, destroy data,
|
|
159
159
|
block writes while they run, or cannot be undone - and searches your
|
|
160
|
-
code for what still uses the column it takes away. Reads SQL,
|
|
161
|
-
Rails
|
|
162
|
-
|
|
160
|
+
code for what still uses the column it takes away. Reads SQL,
|
|
161
|
+
Django, Rails, Alembic, TypeORM, Sequelize, Knex, EF Core and
|
|
162
|
+
Liquibase - so Prisma, Drizzle, Flyway and golang-migrate too,
|
|
163
|
+
since those emit plain SQL - and says so when it cannot read one.
|
|
164
|
+
It judges only the forward path, never the rollback. No cost.
|
|
163
165
|
tewip pick [--diff main...HEAD] [--list] [--out picked.md]
|
|
164
166
|
Which tests could notice this change? Narrows a test run using
|
|
165
167
|
facts rather than a prediction: a test runs when it imports a
|
package/dist/engine/a2a/card.js
CHANGED
|
@@ -51,7 +51,7 @@ export const ANSWERABLE = [
|
|
|
51
51
|
id: 'migration-safety',
|
|
52
52
|
verb: 'schema',
|
|
53
53
|
name: 'What will this migration do to a live database?',
|
|
54
|
-
description: 'Names what breaks the version still running during a rolling deploy, destroys data, blocks writes, or cannot be undone - and searches the repository for code that still uses a column being dropped. Reads SQL, Django, Rails, Alembic and
|
|
54
|
+
description: 'Names what breaks the version still running during a rolling deploy, destroys data, blocks writes, or cannot be undone - and searches the repository for code that still uses a column being dropped. Reads SQL, Django, Rails, Alembic, TypeORM, Sequelize, Knex, EF Core and Liquibase, which covers Prisma, Drizzle and Flyway too.',
|
|
55
55
|
tags: ['database', 'migrations', 'deployment', 'risk'],
|
|
56
56
|
examples: ['Is this migration safe to deploy?', 'Will this drop break the running version?'],
|
|
57
57
|
},
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
const STR = String.raw `['"]([\w.]+)['"]`;
|
|
2
|
+
/** Alembic. */
|
|
3
|
+
/**
|
|
4
|
+
* Sequelize. `queryInterface.removeColumn('Users', 'passwordHash')` names the table and the column
|
|
5
|
+
* in one call, which makes it the simplest of these to read.
|
|
6
|
+
*
|
|
7
|
+
* Written against opencollective-api's real migrations, which also show why the forward path
|
|
8
|
+
* matters more here than anywhere else: a Sequelize migration almost always *adds* in `up` and
|
|
9
|
+
* *removes* in `down`, so a reader that took the whole file would report every harmless migration
|
|
10
|
+
* in the ecosystem as dropping a column.
|
|
11
|
+
*/
|
|
12
|
+
const SEQUELIZE = [
|
|
13
|
+
{
|
|
14
|
+
find: new RegExp(String.raw `removeColumn\s*\(\s*${STR}\s*,\s*${STR}`, 'i'),
|
|
15
|
+
risk: 'breaks-old-code',
|
|
16
|
+
what: (m) => `removes ${m[1]}.${m[2]}`,
|
|
17
|
+
instead: 'deploy code that no longer selects or writes the column, then remove it in a later migration',
|
|
18
|
+
table: (m) => m[1],
|
|
19
|
+
column: (m) => m[2],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
find: new RegExp(String.raw `renameColumn\s*\(\s*${STR}\s*,\s*${STR}`, 'i'),
|
|
23
|
+
risk: 'breaks-old-code',
|
|
24
|
+
what: (m) => `renames ${m[1]}.${m[2]}`,
|
|
25
|
+
instead: 'add the new column, write to both, move the readers, then remove the old one',
|
|
26
|
+
table: (m) => m[1],
|
|
27
|
+
column: (m) => m[2],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
find: new RegExp(String.raw `dropTable\s*\(\s*${STR}`, 'i'),
|
|
31
|
+
risk: 'destroys-data',
|
|
32
|
+
what: (m) => `drops the table ${m[1]}`,
|
|
33
|
+
instead: 'stop reading the table, deploy that, then drop it once nothing refers to it',
|
|
34
|
+
table: (m) => m[1],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
// Sequelize has no CONCURRENTLY option, so an index on a populated table takes the lock.
|
|
38
|
+
find: new RegExp(String.raw `addIndex\s*\(\s*${STR}`, 'i'),
|
|
39
|
+
unless: /concurrently\s*:\s*true/i,
|
|
40
|
+
risk: 'locks-writes',
|
|
41
|
+
what: (m) => `builds an index on ${m[1]} while holding a lock that blocks writes to it`,
|
|
42
|
+
instead: 'build it concurrently with a raw query, or accept the lock if the table is small',
|
|
43
|
+
table: (m) => m[1],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
find: new RegExp(String.raw `changeColumn\s*\(\s*${STR}\s*,\s*${STR}`, 'i'),
|
|
47
|
+
risk: 'locks-writes',
|
|
48
|
+
what: (m) => `changes the type of ${m[1]}.${m[2]}, which can rewrite the whole table`,
|
|
49
|
+
instead: 'add a new column, backfill it in batches, and switch the readers over',
|
|
50
|
+
table: (m) => m[1],
|
|
51
|
+
column: (m) => m[2],
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
/**
|
|
55
|
+
* Knex. The awkward one: the table is named by `knex.schema.table('articles', ...)` and the
|
|
56
|
+
* operation happens inside the callback, so the two are matched together across the lines between
|
|
57
|
+
* them. Matterwiki's real migrations are exactly this shape.
|
|
58
|
+
*/
|
|
59
|
+
const KNEX = [
|
|
60
|
+
{
|
|
61
|
+
find: new RegExp(String.raw `\.(?:table|alterTable)\s*\(\s*${STR}[\s\S]{0,400}?\.dropColumn\s*\(\s*${STR}`, 'i'),
|
|
62
|
+
risk: 'breaks-old-code',
|
|
63
|
+
what: (m) => `drops ${m[1]}.${m[2]}`,
|
|
64
|
+
instead: 'deploy code that no longer selects or writes the column, then drop it in a later migration',
|
|
65
|
+
table: (m) => m[1],
|
|
66
|
+
column: (m) => m[2],
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
find: new RegExp(String.raw `\.(?:table|alterTable)\s*\(\s*${STR}[\s\S]{0,400}?\.renameColumn\s*\(\s*${STR}`, 'i'),
|
|
70
|
+
risk: 'breaks-old-code',
|
|
71
|
+
what: (m) => `renames ${m[1]}.${m[2]}`,
|
|
72
|
+
instead: 'add the new column, write to both, move the readers, then remove the old one',
|
|
73
|
+
table: (m) => m[1],
|
|
74
|
+
column: (m) => m[2],
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
find: new RegExp(String.raw `\.dropTable(?:IfExists)?\s*\(\s*${STR}`, 'i'),
|
|
78
|
+
risk: 'destroys-data',
|
|
79
|
+
what: (m) => `drops the table ${m[1]}`,
|
|
80
|
+
instead: 'stop reading the table, deploy that, then drop it once nothing refers to it',
|
|
81
|
+
table: (m) => m[1],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
find: new RegExp(String.raw `\.(?:table|alterTable)\s*\(\s*${STR}[\s\S]{0,400}?\.notNullable\s*\(`, 'i'),
|
|
85
|
+
risk: 'locks-writes',
|
|
86
|
+
what: (m) => `makes a column on ${m[1]} NOT NULL, which scans every row under a lock`,
|
|
87
|
+
instead: 'add a CHECK ... NOT VALID, validate it, then set NOT NULL - or accept the scan if the table is small',
|
|
88
|
+
table: (m) => m[1],
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
/**
|
|
92
|
+
* EF Core. Its MigrationBuilder calls use named arguments over several lines and in either order,
|
|
93
|
+
* so each operation is matched twice - once for `name` before `table`, once for the reverse -
|
|
94
|
+
* rather than assuming the order Coflnet's real migrations happen to use.
|
|
95
|
+
*/
|
|
96
|
+
const NAMED = (first, second) => String.raw `(?:${first}:\s*${STR}[\s\S]{0,200}?${second}:\s*${STR}|${second}:\s*${STR}[\s\S]{0,200}?${first}:\s*${STR})`;
|
|
97
|
+
/** Whichever order the arguments came in, the two names are in 1,2 or in 4,3. */
|
|
98
|
+
const pair = (m) => (m[1] !== undefined ? [m[1], m[2]] : [m[4], m[3]]);
|
|
99
|
+
const EFCORE = [
|
|
100
|
+
{
|
|
101
|
+
find: new RegExp(String.raw `migrationBuilder\.DropColumn\s*\(\s*${NAMED('name', 'table')}`, 'i'),
|
|
102
|
+
risk: 'breaks-old-code',
|
|
103
|
+
what: (m) => `drops ${pair(m)[1]}.${pair(m)[0]}`,
|
|
104
|
+
instead: 'deploy code that no longer selects or writes the column, then drop it in a later migration',
|
|
105
|
+
table: (m) => pair(m)[1],
|
|
106
|
+
column: (m) => pair(m)[0],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
find: new RegExp(String.raw `migrationBuilder\.DropTable\s*\(\s*name:\s*${STR}`, 'i'),
|
|
110
|
+
risk: 'destroys-data',
|
|
111
|
+
what: (m) => `drops the table ${m[1]}`,
|
|
112
|
+
instead: 'stop reading the table, deploy that, then drop it once nothing refers to it',
|
|
113
|
+
table: (m) => m[1],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
find: new RegExp(String.raw `migrationBuilder\.RenameColumn\s*\(\s*${NAMED('name', 'table')}`, 'i'),
|
|
117
|
+
risk: 'breaks-old-code',
|
|
118
|
+
what: (m) => `renames ${pair(m)[1]}.${pair(m)[0]}`,
|
|
119
|
+
instead: 'add the new column, write to both, move the readers, then remove the old one',
|
|
120
|
+
table: (m) => pair(m)[1],
|
|
121
|
+
column: (m) => pair(m)[0],
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
find: new RegExp(String.raw `migrationBuilder\.CreateIndex\s*\(\s*${NAMED('name', 'table')}`, 'i'),
|
|
125
|
+
risk: 'locks-writes',
|
|
126
|
+
what: (m) => `builds an index on ${pair(m)[1]} while holding a lock that blocks writes to it`,
|
|
127
|
+
instead: 'build it concurrently with migrationBuilder.Sql, or accept the lock if the table is small',
|
|
128
|
+
table: (m) => pair(m)[1],
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
find: new RegExp(String.raw `migrationBuilder\.AlterColumn<[^>]*>\s*\(\s*${NAMED('name', 'table')}`, 'i'),
|
|
132
|
+
risk: 'locks-writes',
|
|
133
|
+
what: (m) => `alters ${pair(m)[1]}.${pair(m)[0]}, which can rewrite the whole table`,
|
|
134
|
+
instead: 'add a new column, backfill it in batches, and switch the readers over',
|
|
135
|
+
table: (m) => pair(m)[1],
|
|
136
|
+
column: (m) => pair(m)[0],
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
/**
|
|
140
|
+
* Liquibase. A changelog is XML or YAML rather than code, and the operation is the element name
|
|
141
|
+
* with the table on `tableName`. DEFRA's real changelogs are the shape this was written against.
|
|
142
|
+
*/
|
|
143
|
+
const XML_TABLE = String.raw `tableName\s*=\s*["']([\w.]+)["']`;
|
|
144
|
+
const LIQUIBASE = [
|
|
145
|
+
{
|
|
146
|
+
find: new RegExp(String.raw `<dropColumn[^>]*${XML_TABLE}[\s\S]{0,300}?<column[^>]*name\s*=\s*["']([\w.]+)["']`, 'i'),
|
|
147
|
+
risk: 'breaks-old-code',
|
|
148
|
+
what: (m) => `drops ${m[1]}.${m[2]}`,
|
|
149
|
+
instead: 'deploy code that no longer selects or writes the column, then drop it in a later changeSet',
|
|
150
|
+
table: (m) => m[1],
|
|
151
|
+
column: (m) => m[2],
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
find: new RegExp(String.raw `<dropColumn[^>]*columnName\s*=\s*["']([\w.]+)["'][^>]*${XML_TABLE}`, 'i'),
|
|
155
|
+
risk: 'breaks-old-code',
|
|
156
|
+
what: (m) => `drops ${m[2]}.${m[1]}`,
|
|
157
|
+
instead: 'deploy code that no longer selects or writes the column, then drop it in a later changeSet',
|
|
158
|
+
table: (m) => m[2],
|
|
159
|
+
column: (m) => m[1],
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
find: new RegExp(String.raw `<dropTable[^>]*${XML_TABLE}`, 'i'),
|
|
163
|
+
risk: 'destroys-data',
|
|
164
|
+
what: (m) => `drops the table ${m[1]}`,
|
|
165
|
+
instead: 'stop reading the table, deploy that, then drop it once nothing refers to it',
|
|
166
|
+
table: (m) => m[1],
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
find: new RegExp(String.raw `<renameColumn[^>]*${XML_TABLE}`, 'i'),
|
|
170
|
+
risk: 'breaks-old-code',
|
|
171
|
+
what: (m) => `renames a column on ${m[1]}`,
|
|
172
|
+
instead: 'add the new column, write to both, move the readers, then remove the old one',
|
|
173
|
+
table: (m) => m[1],
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
find: new RegExp(String.raw `<addNotNullConstraint[^>]*${XML_TABLE}`, 'i'),
|
|
177
|
+
risk: 'locks-writes',
|
|
178
|
+
what: (m) => `makes a column on ${m[1]} NOT NULL, which scans every row under a lock`,
|
|
179
|
+
instead: 'add a CHECK ... NOT VALID, validate it, then set NOT NULL - or accept the scan if the table is small',
|
|
180
|
+
table: (m) => m[1],
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
find: new RegExp(String.raw `<createIndex[^>]*${XML_TABLE}`, 'i'),
|
|
184
|
+
risk: 'locks-writes',
|
|
185
|
+
what: (m) => `builds an index on ${m[1]} while holding a lock that blocks writes to it`,
|
|
186
|
+
instead: 'build it concurrently with a <sql> changeSet, or accept the lock if the table is small',
|
|
187
|
+
table: (m) => m[1],
|
|
188
|
+
},
|
|
189
|
+
];
|
|
190
|
+
export { SEQUELIZE, KNEX, EFCORE, LIQUIBASE };
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
const STR = String.raw `['"]([\w.]+)['"]`;
|
|
2
|
+
/** Django's operations. `model_name` and `name` are the table and the column. */
|
|
3
|
+
const DJANGO = [
|
|
4
|
+
{
|
|
5
|
+
find: new RegExp(String.raw `migrations\.RemoveField\([\s\S]{0,200}?model_name=${STR}[\s\S]{0,200}?name=${STR}`, 'i'),
|
|
6
|
+
risk: 'breaks-old-code',
|
|
7
|
+
what: (m) => `removes the field ${m[2]} from ${m[1]}`,
|
|
8
|
+
instead: 'stop reading and writing the field, deploy that, then remove it in a later migration. The version still running during a rolling deploy will select a column that no longer exists',
|
|
9
|
+
table: (m) => m[1],
|
|
10
|
+
column: (m) => m[2],
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
find: new RegExp(String.raw `migrations\.RenameField\([\s\S]{0,200}?model_name=${STR}[\s\S]{0,200}?old_name=${STR}`, 'i'),
|
|
14
|
+
risk: 'breaks-old-code',
|
|
15
|
+
what: (m) => `renames ${m[1]}.${m[2]}`,
|
|
16
|
+
instead: 'add the new field, write to both, move the readers, then remove the old one',
|
|
17
|
+
table: (m) => m[1],
|
|
18
|
+
column: (m) => m[2],
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
find: new RegExp(String.raw `migrations\.DeleteModel\([\s\S]{0,120}?${STR}`, 'i'),
|
|
22
|
+
risk: 'destroys-data',
|
|
23
|
+
what: (m) => `deletes the model ${m[1]} and its table`,
|
|
24
|
+
instead: 'stop using it, ship that, confirm nothing reads it, and delete it later',
|
|
25
|
+
table: (m) => m[1],
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
find: new RegExp(String.raw `migrations\.AddField\([\s\S]{0,400}?null=False(?![\s\S]{0,400}?default=)`, 'i'),
|
|
29
|
+
risk: 'breaks-old-code',
|
|
30
|
+
what: () => 'adds a field that cannot be null and has no default',
|
|
31
|
+
instead: 'add it with null=True or a default. Without one, Django rewrites the table and every insert from the running version fails',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
find: new RegExp(String.raw `migrations\.AlterField\([\s\S]{0,400}?null=False`, 'i'),
|
|
35
|
+
risk: 'locks-writes',
|
|
36
|
+
what: () => 'makes an existing field NOT NULL, which reads every row under a lock',
|
|
37
|
+
instead: 'backfill first, add a validated constraint, then alter - or use SeparateDatabaseAndState with the safe SQL',
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
// A data migration. Django wraps a migration in one transaction, so this holds it open for as long as
|
|
41
|
+
// the work takes - fine for a hundred rows, an outage for a hundred million.
|
|
42
|
+
find: /migrations\.RunPython\(/,
|
|
43
|
+
unless: /atomic\s*=\s*False/,
|
|
44
|
+
risk: 'slow',
|
|
45
|
+
what: () => 'runs application code against the data inside the migration transaction',
|
|
46
|
+
instead: 'for a large table, work in batches with atomic = False on the migration and make it resumable. For a small one this is fine - Tewip cannot see how many rows there are',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
find: /AddIndex\(|AddConstraint\(/i,
|
|
50
|
+
unless: /AddIndexConcurrently|AddConstraintNotValid|atomic\s*=\s*False/,
|
|
51
|
+
risk: 'locks-writes',
|
|
52
|
+
what: () => 'adds an index or constraint, which Django builds with a lock unless told otherwise',
|
|
53
|
+
instead: 'AddIndexConcurrently / AddConstraintNotValid from django.contrib.postgres.operations, with atomic = False on the migration',
|
|
54
|
+
},
|
|
55
|
+
];
|
|
56
|
+
/** Rails migrations. */
|
|
57
|
+
const RAILS = [
|
|
58
|
+
{
|
|
59
|
+
find: new RegExp(String.raw `remove_column\s*[\s(]\s*:?(\w+)\s*,\s*:?(\w+)`, 'i'),
|
|
60
|
+
risk: 'breaks-old-code',
|
|
61
|
+
what: (m) => `removes ${m[1]}.${m[2]}`,
|
|
62
|
+
instead: "ignore the column in the model (self.ignored_columns), deploy that, then remove it. Rails caches the column list, so the running version keeps selecting it",
|
|
63
|
+
table: (m) => m[1],
|
|
64
|
+
column: (m) => m[2],
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
find: new RegExp(String.raw `rename_column\s*[\s(]\s*:?(\w+)\s*,\s*:?(\w+)`, 'i'),
|
|
68
|
+
risk: 'breaks-old-code',
|
|
69
|
+
what: (m) => `renames ${m[1]}.${m[2]}`,
|
|
70
|
+
instead: 'add the new column, write to both, move the readers, then remove the old one',
|
|
71
|
+
table: (m) => m[1],
|
|
72
|
+
column: (m) => m[2],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
find: new RegExp(String.raw `drop_table\s*[\s(]\s*:?(\w+)`, 'i'),
|
|
76
|
+
risk: 'destroys-data',
|
|
77
|
+
what: (m) => `drops the table ${m[1]}`,
|
|
78
|
+
instead: 'stop using it, ship that, then drop it later',
|
|
79
|
+
table: (m) => m[1],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
find: /add_index\s*[\s(]/i,
|
|
83
|
+
unless: /algorithm:\s*:concurrently/,
|
|
84
|
+
risk: 'locks-writes',
|
|
85
|
+
what: () => 'adds an index without algorithm: :concurrently, so writes to the table block while it builds',
|
|
86
|
+
instead: 'add_index ..., algorithm: :concurrently, with disable_ddl_transaction! on the migration',
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
find: new RegExp(String.raw `change_column\s*[\s(]\s*:?(\w+)\s*,\s*:?(\w+)`, 'i'),
|
|
90
|
+
risk: 'locks-writes',
|
|
91
|
+
what: (m) => `changes the type of ${m[1]}.${m[2]}, which usually rewrites the table`,
|
|
92
|
+
instead: 'add a new column, backfill in batches, switch the readers, then drop the old one',
|
|
93
|
+
table: (m) => m[1],
|
|
94
|
+
column: (m) => m[2],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
find: new RegExp(String.raw `change_column_null\s*[\s(]\s*:?(\w+)\s*,\s*:?(\w+)\s*,\s*false`, 'i'),
|
|
98
|
+
risk: 'locks-writes',
|
|
99
|
+
what: (m) => `makes ${m[1]}.${m[2]} NOT NULL, which reads every row under a lock`,
|
|
100
|
+
instead: 'add a validated check constraint first, then set the column NOT NULL',
|
|
101
|
+
table: (m) => m[1],
|
|
102
|
+
column: (m) => m[2],
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
const ALEMBIC = [
|
|
106
|
+
{
|
|
107
|
+
find: new RegExp(String.raw `op\.drop_column\(\s*${STR}\s*,\s*${STR}`, 'i'),
|
|
108
|
+
risk: 'breaks-old-code',
|
|
109
|
+
what: (m) => `drops ${m[1]}.${m[2]}`,
|
|
110
|
+
instead: 'stop reading and writing it, deploy that, then drop it in a later revision',
|
|
111
|
+
table: (m) => m[1],
|
|
112
|
+
column: (m) => m[2],
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
find: new RegExp(String.raw `op\.drop_table\(\s*${STR}`, 'i'),
|
|
116
|
+
risk: 'destroys-data',
|
|
117
|
+
what: (m) => `drops the table ${m[1]}`,
|
|
118
|
+
instead: 'stop using it, ship that, then drop it later',
|
|
119
|
+
table: (m) => m[1],
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
find: new RegExp(String.raw `op\.alter_column\([\s\S]{0,300}?nullable=False`, 'i'),
|
|
123
|
+
risk: 'locks-writes',
|
|
124
|
+
what: () => 'makes a column NOT NULL, which reads every row under a lock',
|
|
125
|
+
instead: 'backfill, add a validated constraint, then alter the column',
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
find: /op\.create_index\(/i,
|
|
129
|
+
unless: /postgresql_concurrently\s*=\s*True/,
|
|
130
|
+
risk: 'locks-writes',
|
|
131
|
+
what: () => 'builds an index without postgresql_concurrently, so writes block while it builds',
|
|
132
|
+
instead: 'op.create_index(..., postgresql_concurrently=True) with a non-transactional revision',
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
find: new RegExp(String.raw `op\.rename_table\(\s*${STR}`, 'i'),
|
|
136
|
+
risk: 'breaks-old-code',
|
|
137
|
+
what: (m) => `renames the table ${m[1]}`,
|
|
138
|
+
instead: 'move the readers first, or keep a view with the old name',
|
|
139
|
+
table: (m) => m[1],
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
// TypeORM has no operations of its own: unlike Django's `RemoveField` or Rails's `remove_column`,
|
|
143
|
+
// a TypeORM migration contains SQL and nothing else, handed to queryRunner.query(). So its list is
|
|
144
|
+
// empty on purpose and every finding comes from reading that SQL, which is the same reader the
|
|
145
|
+
// `.sql` dialect uses. The exhaustive Record is what made this a compile error rather than a
|
|
146
|
+
// TypeORM migration silently producing no findings at all.
|
|
147
|
+
export { DJANGO, RAILS, ALEMBIC };
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ALEMBIC, DJANGO, RAILS } from './dialect-rules.js';
|
|
2
|
+
import { EFCORE, KNEX, LIQUIBASE, SEQUELIZE } from './dialect-rules-node.js';
|
|
1
3
|
export function dialectOf(file, text) {
|
|
2
4
|
const path = file.split('\\').join('/').toLowerCase();
|
|
3
5
|
if (path.endsWith('.sql'))
|
|
@@ -17,158 +19,28 @@ export function dialectOf(file, text) {
|
|
|
17
19
|
// at all - and on a Node backend that is most of them.
|
|
18
20
|
if (/\bMigrationInterface\b|\bqueryRunner\.query\s*\(/.test(text))
|
|
19
21
|
return 'typeorm';
|
|
22
|
+
if (/\bqueryInterface\.\w+\s*\(/.test(text))
|
|
23
|
+
return 'sequelize';
|
|
24
|
+
if (/\bknex\.schema\b|\bexports\.(?:up|down)\s*=\s*(?:async\s*)?function\s*\(\s*knex\b/.test(text))
|
|
25
|
+
return 'knex';
|
|
26
|
+
if (/\bmigrationBuilder\.\w+|\b:\s*Migration\b[\s\S]*\bMigrationBuilder\b/.test(text))
|
|
27
|
+
return 'efcore';
|
|
28
|
+
if (/<databaseChangeLog\b|\bdatabaseChangeLog\s*:/.test(text))
|
|
29
|
+
return 'liquibase';
|
|
20
30
|
if (/(^|\/)(migrations?|db\/migrate)\//.test(path))
|
|
21
31
|
return 'unknown';
|
|
22
32
|
return 'unknown';
|
|
23
33
|
}
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
find: new RegExp(String.raw `migrations\.RenameField\([\s\S]{0,200}?model_name=${STR}[\s\S]{0,200}?old_name=${STR}`, 'i'),
|
|
37
|
-
risk: 'breaks-old-code',
|
|
38
|
-
what: (m) => `renames ${m[1]}.${m[2]}`,
|
|
39
|
-
instead: 'add the new field, write to both, move the readers, then remove the old one',
|
|
40
|
-
table: (m) => m[1],
|
|
41
|
-
column: (m) => m[2],
|
|
42
|
-
},
|
|
43
|
-
{
|
|
44
|
-
find: new RegExp(String.raw `migrations\.DeleteModel\([\s\S]{0,120}?${STR}`, 'i'),
|
|
45
|
-
risk: 'destroys-data',
|
|
46
|
-
what: (m) => `deletes the model ${m[1]} and its table`,
|
|
47
|
-
instead: 'stop using it, ship that, confirm nothing reads it, and delete it later',
|
|
48
|
-
table: (m) => m[1],
|
|
49
|
-
},
|
|
50
|
-
{
|
|
51
|
-
find: new RegExp(String.raw `migrations\.AddField\([\s\S]{0,400}?null=False(?![\s\S]{0,400}?default=)`, 'i'),
|
|
52
|
-
risk: 'breaks-old-code',
|
|
53
|
-
what: () => 'adds a field that cannot be null and has no default',
|
|
54
|
-
instead: 'add it with null=True or a default. Without one, Django rewrites the table and every insert from the running version fails',
|
|
55
|
-
},
|
|
56
|
-
{
|
|
57
|
-
find: new RegExp(String.raw `migrations\.AlterField\([\s\S]{0,400}?null=False`, 'i'),
|
|
58
|
-
risk: 'locks-writes',
|
|
59
|
-
what: () => 'makes an existing field NOT NULL, which reads every row under a lock',
|
|
60
|
-
instead: 'backfill first, add a validated constraint, then alter - or use SeparateDatabaseAndState with the safe SQL',
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
// A data migration. Django wraps a migration in one transaction, so this holds it open for as long as
|
|
64
|
-
// the work takes - fine for a hundred rows, an outage for a hundred million.
|
|
65
|
-
find: /migrations\.RunPython\(/,
|
|
66
|
-
unless: /atomic\s*=\s*False/,
|
|
67
|
-
risk: 'slow',
|
|
68
|
-
what: () => 'runs application code against the data inside the migration transaction',
|
|
69
|
-
instead: 'for a large table, work in batches with atomic = False on the migration and make it resumable. For a small one this is fine - Tewip cannot see how many rows there are',
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
find: /AddIndex\(|AddConstraint\(/i,
|
|
73
|
-
unless: /AddIndexConcurrently|AddConstraintNotValid|atomic\s*=\s*False/,
|
|
74
|
-
risk: 'locks-writes',
|
|
75
|
-
what: () => 'adds an index or constraint, which Django builds with a lock unless told otherwise',
|
|
76
|
-
instead: 'AddIndexConcurrently / AddConstraintNotValid from django.contrib.postgres.operations, with atomic = False on the migration',
|
|
77
|
-
},
|
|
78
|
-
];
|
|
79
|
-
/** Rails migrations. */
|
|
80
|
-
const RAILS = [
|
|
81
|
-
{
|
|
82
|
-
find: new RegExp(String.raw `remove_column\s*[\s(]\s*:?(\w+)\s*,\s*:?(\w+)`, 'i'),
|
|
83
|
-
risk: 'breaks-old-code',
|
|
84
|
-
what: (m) => `removes ${m[1]}.${m[2]}`,
|
|
85
|
-
instead: "ignore the column in the model (self.ignored_columns), deploy that, then remove it. Rails caches the column list, so the running version keeps selecting it",
|
|
86
|
-
table: (m) => m[1],
|
|
87
|
-
column: (m) => m[2],
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
find: new RegExp(String.raw `rename_column\s*[\s(]\s*:?(\w+)\s*,\s*:?(\w+)`, 'i'),
|
|
91
|
-
risk: 'breaks-old-code',
|
|
92
|
-
what: (m) => `renames ${m[1]}.${m[2]}`,
|
|
93
|
-
instead: 'add the new column, write to both, move the readers, then remove the old one',
|
|
94
|
-
table: (m) => m[1],
|
|
95
|
-
column: (m) => m[2],
|
|
96
|
-
},
|
|
97
|
-
{
|
|
98
|
-
find: new RegExp(String.raw `drop_table\s*[\s(]\s*:?(\w+)`, 'i'),
|
|
99
|
-
risk: 'destroys-data',
|
|
100
|
-
what: (m) => `drops the table ${m[1]}`,
|
|
101
|
-
instead: 'stop using it, ship that, then drop it later',
|
|
102
|
-
table: (m) => m[1],
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
find: /add_index\s*[\s(]/i,
|
|
106
|
-
unless: /algorithm:\s*:concurrently/,
|
|
107
|
-
risk: 'locks-writes',
|
|
108
|
-
what: () => 'adds an index without algorithm: :concurrently, so writes to the table block while it builds',
|
|
109
|
-
instead: 'add_index ..., algorithm: :concurrently, with disable_ddl_transaction! on the migration',
|
|
110
|
-
},
|
|
111
|
-
{
|
|
112
|
-
find: new RegExp(String.raw `change_column\s*[\s(]\s*:?(\w+)\s*,\s*:?(\w+)`, 'i'),
|
|
113
|
-
risk: 'locks-writes',
|
|
114
|
-
what: (m) => `changes the type of ${m[1]}.${m[2]}, which usually rewrites the table`,
|
|
115
|
-
instead: 'add a new column, backfill in batches, switch the readers, then drop the old one',
|
|
116
|
-
table: (m) => m[1],
|
|
117
|
-
column: (m) => m[2],
|
|
118
|
-
},
|
|
119
|
-
{
|
|
120
|
-
find: new RegExp(String.raw `change_column_null\s*[\s(]\s*:?(\w+)\s*,\s*:?(\w+)\s*,\s*false`, 'i'),
|
|
121
|
-
risk: 'locks-writes',
|
|
122
|
-
what: (m) => `makes ${m[1]}.${m[2]} NOT NULL, which reads every row under a lock`,
|
|
123
|
-
instead: 'add a validated check constraint first, then set the column NOT NULL',
|
|
124
|
-
table: (m) => m[1],
|
|
125
|
-
column: (m) => m[2],
|
|
126
|
-
},
|
|
127
|
-
];
|
|
128
|
-
/** Alembic. */
|
|
129
|
-
const ALEMBIC = [
|
|
130
|
-
{
|
|
131
|
-
find: new RegExp(String.raw `op\.drop_column\(\s*${STR}\s*,\s*${STR}`, 'i'),
|
|
132
|
-
risk: 'breaks-old-code',
|
|
133
|
-
what: (m) => `drops ${m[1]}.${m[2]}`,
|
|
134
|
-
instead: 'stop reading and writing it, deploy that, then drop it in a later revision',
|
|
135
|
-
table: (m) => m[1],
|
|
136
|
-
column: (m) => m[2],
|
|
137
|
-
},
|
|
138
|
-
{
|
|
139
|
-
find: new RegExp(String.raw `op\.drop_table\(\s*${STR}`, 'i'),
|
|
140
|
-
risk: 'destroys-data',
|
|
141
|
-
what: (m) => `drops the table ${m[1]}`,
|
|
142
|
-
instead: 'stop using it, ship that, then drop it later',
|
|
143
|
-
table: (m) => m[1],
|
|
144
|
-
},
|
|
145
|
-
{
|
|
146
|
-
find: new RegExp(String.raw `op\.alter_column\([\s\S]{0,300}?nullable=False`, 'i'),
|
|
147
|
-
risk: 'locks-writes',
|
|
148
|
-
what: () => 'makes a column NOT NULL, which reads every row under a lock',
|
|
149
|
-
instead: 'backfill, add a validated constraint, then alter the column',
|
|
150
|
-
},
|
|
151
|
-
{
|
|
152
|
-
find: /op\.create_index\(/i,
|
|
153
|
-
unless: /postgresql_concurrently\s*=\s*True/,
|
|
154
|
-
risk: 'locks-writes',
|
|
155
|
-
what: () => 'builds an index without postgresql_concurrently, so writes block while it builds',
|
|
156
|
-
instead: 'op.create_index(..., postgresql_concurrently=True) with a non-transactional revision',
|
|
157
|
-
},
|
|
158
|
-
{
|
|
159
|
-
find: new RegExp(String.raw `op\.rename_table\(\s*${STR}`, 'i'),
|
|
160
|
-
risk: 'breaks-old-code',
|
|
161
|
-
what: (m) => `renames the table ${m[1]}`,
|
|
162
|
-
instead: 'move the readers first, or keep a view with the old name',
|
|
163
|
-
table: (m) => m[1],
|
|
164
|
-
},
|
|
165
|
-
];
|
|
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: [] };
|
|
34
|
+
const BY_DIALECT = {
|
|
35
|
+
django: DJANGO,
|
|
36
|
+
rails: RAILS,
|
|
37
|
+
alembic: ALEMBIC,
|
|
38
|
+
typeorm: [],
|
|
39
|
+
sequelize: SEQUELIZE,
|
|
40
|
+
knex: KNEX,
|
|
41
|
+
efcore: EFCORE,
|
|
42
|
+
liquibase: LIQUIBASE,
|
|
43
|
+
};
|
|
172
44
|
/** Findings from a framework migration, with the line each one sits on. */
|
|
173
45
|
export function readFramework(dialect, text) {
|
|
174
46
|
const lines = text.split(String.fromCharCode(10));
|
|
@@ -230,10 +102,24 @@ export function forwardOnly(dialect, text) {
|
|
|
230
102
|
(dialect === 'sql' && /^\s*--\s*(?:\+migrate\s+Down|\+goose\s+Down|\/\/@UNDO|Down\b)/i.test(line)) ||
|
|
231
103
|
// TypeORM's rollback is `public async down(queryRunner: QueryRunner)`. Reading it as part of
|
|
232
104
|
// 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))
|
|
105
|
+
(dialect === 'typeorm' && /^\s*(?:public\s+)?async\s+down\s*\(/.test(line)) ||
|
|
106
|
+
// These three matter more than the rest. A Sequelize or Knex migration almost always *adds*
|
|
107
|
+
// in up and *removes* in down, and an EF Core Down() is the mirror image of its Up(), so a
|
|
108
|
+
// reader that took the whole file would report nearly every harmless migration in those
|
|
109
|
+
// ecosystems as dropping a column. Verified on real migrations from opencollective-api,
|
|
110
|
+
// Matterwiki and Coflnet, all three of which have their only destructive call in down.
|
|
111
|
+
(dialect === 'sequelize' && /^\s*down\s*:/.test(line)) ||
|
|
112
|
+
(dialect === 'knex' && /^\s*(?:exports\.down|module\.exports\.down)\s*=/.test(line)) ||
|
|
113
|
+
(dialect === 'efcore' && /^\s*protected\s+override\s+void\s+Down\s*\(/.test(line)) ||
|
|
114
|
+
// Liquibase puts the reverse inside the change itself rather than in a second method.
|
|
115
|
+
(dialect === 'liquibase' && /^\s*<rollback\b/.test(line));
|
|
234
116
|
if (down) {
|
|
235
117
|
// To the end of the block: the next line at the same indentation or less that starts something new.
|
|
236
|
-
|
|
118
|
+
// A lone brace is punctuation, not the start of something new. C# puts the opening `{` of a
|
|
119
|
+
// method on its own line at the same indent as the signature, so without `{` here the block
|
|
120
|
+
// ended one line after it began and the whole of EF Core's Down() was read as part of the
|
|
121
|
+
// change - which reported Coflnet's real migration as dropping three columns it adds.
|
|
122
|
+
blankFrom(i, (l, indent) => l.trim() !== '' && indentOf(l) <= indent && !/^\s*(end|\}|\{)\s*$/.test(l), indentOf(line));
|
|
237
123
|
continue;
|
|
238
124
|
}
|
|
239
125
|
// Rails's `direction.down { ... }` is usually one line.
|
|
@@ -70,7 +70,7 @@ export function readMigration(root, file) {
|
|
|
70
70
|
file,
|
|
71
71
|
dialect,
|
|
72
72
|
findings: [],
|
|
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
|
|
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, TypeORM, Sequelize, Knex, EF Core and Liquibase migrations',
|
|
74
74
|
irreversible: false,
|
|
75
75
|
};
|
|
76
76
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.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": [
|