tewip-ai 0.48.0 → 0.50.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 +1 -1
- package/dist/engine/access/markup.js +17 -1
- package/dist/engine/schema/dialect-rules.js +47 -0
- package/dist/engine/schema/frameworks.js +8 -1
- package/dist/engine/schema/schema-command.js +8 -4
- package/dist/engine/start/start-command.js +21 -5
- package/dist/engine/vouch/vouch.js +9 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -927,7 +927,7 @@ That last section is the part no SQL linter does: it searches your own code for
|
|
|
927
927
|
away. "This drops `users.nickname`, and four files still read it" is a different sentence from "dropping a
|
|
928
928
|
column is risky", and it is the one that stops the outage.
|
|
929
929
|
|
|
930
|
-
It reads **SQL, Django, Rails, Alembic, TypeORM, Sequelize, Knex, EF Core and Liquibase** migrations — which covers Prisma, Drizzle, Flyway
|
|
930
|
+
It reads **SQL, Django, Rails, Alembic, TypeORM, Sequelize, Knex, Laravel, EF Core and Liquibase** migrations — which covers Prisma, Drizzle, Flyway
|
|
931
931
|
and golang-migrate too, since those all emit plain `.sql`. It judges **only the forward path** (a rollback step
|
|
932
932
|
contains the mirror image of the change, so reading it says a migration that creates a table destroys
|
|
933
933
|
one), and stays quiet when the team already did the safe thing — `atomic = False`, `algorithm:
|
package/dist/cli/help.js
CHANGED
|
@@ -167,7 +167,7 @@ Usage:
|
|
|
167
167
|
that break the version still running during a deploy, destroy data,
|
|
168
168
|
block writes while they run, or cannot be undone - and searches your
|
|
169
169
|
code for what still uses the column it takes away. Reads SQL,
|
|
170
|
-
Django, Rails, Alembic, TypeORM, Sequelize, Knex, EF Core and
|
|
170
|
+
Django, Rails, Alembic, TypeORM, Sequelize, Knex, Laravel, EF Core and
|
|
171
171
|
Liquibase - so Prisma, Drizzle, Flyway and golang-migrate too,
|
|
172
172
|
since those emit plain SQL - and says so when it cannot read one.
|
|
173
173
|
It judges only the forward path, never the rollback. No cost.
|
|
@@ -50,7 +50,23 @@ function withoutNonMarkup(source, jsLike) {
|
|
|
50
50
|
.replace(/\{\/\*[\s\S]*?\*\/\}/g, blank)
|
|
51
51
|
.replace(/<script\b[\s\S]*?<\/script\s*>/gi, blank)
|
|
52
52
|
.replace(/<style\b[\s\S]*?<\/style\s*>/gi, blank);
|
|
53
|
-
|
|
53
|
+
// A template expression is code, not markup, and real templates put markup *inside* it: GOV.UK's
|
|
54
|
+
// own examples gallery passes `{{ govukLabel({ "text": "<h1> govuk-label--xl" }) }}`, and reading
|
|
55
|
+
// that `<h1>` as a heading poisoned the outline so badly that twelve heading skips were reported
|
|
56
|
+
// on a page whose headings are perfectly ordered - on the design system built by the UK
|
|
57
|
+
// government's accessibility specialists, which is as wrong as this check can be.
|
|
58
|
+
//
|
|
59
|
+
// The delimiters are kept and only the contents blanked, which matters: `<button>{{ label }}</button>`
|
|
60
|
+
// must still read as content this file cannot see, so the button is asked about rather than
|
|
61
|
+
// called empty, and `alt="{{ alt }}"` must still count as an alt attribute that is present.
|
|
62
|
+
const withoutExpressions = stripped
|
|
63
|
+
// A template comment is a comment: Jinja, Nunjucks and Twig write `{# ... #}`, and Django and
|
|
64
|
+
// Jinja also have a `{% comment %}` block. Neither renders, so neither is markup.
|
|
65
|
+
.replace(/\{#[\s\S]*?#\}/g, blank)
|
|
66
|
+
.replace(/\{%-?\s*comment\s*-?%\}[\s\S]*?\{%-?\s*endcomment\s*-?%\}/gi, blank)
|
|
67
|
+
.replace(/\{\{[\s\S]*?\}\}/g, (m) => `{{${m.slice(2, -2).replace(/[^\n]/g, ' ')}}}`)
|
|
68
|
+
.replace(/\{%[\s\S]*?%\}/g, (m) => `{%${m.slice(2, -2).replace(/[^\n]/g, ' ')}%}`);
|
|
69
|
+
return jsLike ? withoutExpressions.replace(/`(?:[^`\\]|\\[\s\S])*`/g, blank) : withoutExpressions;
|
|
54
70
|
}
|
|
55
71
|
/** Files whose markup is embedded in JavaScript, where a backtick string is not page markup. */
|
|
56
72
|
export const JS_LIKE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro)$/i;
|
|
@@ -144,4 +144,51 @@ const ALEMBIC = [
|
|
|
144
144
|
// empty on purpose and every finding comes from reading that SQL, which is the same reader the
|
|
145
145
|
// `.sql` dialect uses. The exhaustive Record is what made this a compile error rather than a
|
|
146
146
|
// TypeORM migration silently producing no findings at all.
|
|
147
|
+
/**
|
|
148
|
+
* Laravel. Found missing by running Tewip over firefly-iii, a real finance application with **61
|
|
149
|
+
* migrations Tewip could not read a word of** - and Laravel is one of the most used web frameworks
|
|
150
|
+
* there is, while `schema` advertised nine dialects.
|
|
151
|
+
*
|
|
152
|
+
* Structurally it is Knex's shape: the table is named by `Schema::table('accounts', ...)` and the
|
|
153
|
+
* operation happens inside the closure, so the two are matched together across the lines between.
|
|
154
|
+
*/
|
|
155
|
+
export const LARAVEL = [
|
|
156
|
+
{
|
|
157
|
+
find: new RegExp(String.raw `Schema::(?:table|connection\(.*?\)->table)\(\s*${STR}[\s\S]{0,600}?->dropColumn\(\s*${STR}`, 'i'),
|
|
158
|
+
risk: 'breaks-old-code',
|
|
159
|
+
what: (m) => `drops ${m[1]}.${m[2]}`,
|
|
160
|
+
instead: 'deploy code that no longer selects or writes the column, then drop it in a later migration. Eloquent caches the column list, so the running version keeps selecting it',
|
|
161
|
+
table: (m) => m[1],
|
|
162
|
+
column: (m) => m[2],
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
find: new RegExp(String.raw `Schema::(?:table|connection\(.*?\)->table)\(\s*${STR}[\s\S]{0,600}?->renameColumn\(\s*${STR}`, 'i'),
|
|
166
|
+
risk: 'breaks-old-code',
|
|
167
|
+
what: (m) => `renames ${m[1]}.${m[2]}`,
|
|
168
|
+
instead: 'add the new column, write to both, move the readers, then remove the old one',
|
|
169
|
+
table: (m) => m[1],
|
|
170
|
+
column: (m) => m[2],
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
find: new RegExp(String.raw `Schema::drop(?:IfExists)?\(\s*${STR}`, 'i'),
|
|
174
|
+
risk: 'destroys-data',
|
|
175
|
+
what: (m) => `drops the table ${m[1]}`,
|
|
176
|
+
instead: 'stop reading the table, deploy that, then drop it once nothing refers to it',
|
|
177
|
+
table: (m) => m[1],
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
find: new RegExp(String.raw `Schema::(?:table|connection\(.*?\)->table)\(\s*${STR}[\s\S]{0,600}?->index\(`, 'i'),
|
|
181
|
+
risk: 'locks-writes',
|
|
182
|
+
what: (m) => `builds an index on ${m[1]} while holding a lock that blocks writes to it`,
|
|
183
|
+
instead: 'build it with a raw CREATE INDEX CONCURRENTLY through DB::statement, or accept the lock if the table is small',
|
|
184
|
+
table: (m) => m[1],
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
find: new RegExp(String.raw `Schema::(?:table|connection\(.*?\)->table)\(\s*${STR}[\s\S]{0,600}?->foreign\(`, 'i'),
|
|
188
|
+
risk: 'locks-writes',
|
|
189
|
+
what: (m) => `adds a foreign key to ${m[1]}, which checks every existing row under a lock on both tables`,
|
|
190
|
+
instead: 'add it NOT VALID and validate it afterwards, which takes a weaker lock',
|
|
191
|
+
table: (m) => m[1],
|
|
192
|
+
},
|
|
193
|
+
];
|
|
147
194
|
export { DJANGO, RAILS, ALEMBIC };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ALEMBIC, DJANGO, RAILS } from './dialect-rules.js';
|
|
1
|
+
import { ALEMBIC, DJANGO, LARAVEL, RAILS } from './dialect-rules.js';
|
|
2
2
|
import { EFCORE, KNEX, LIQUIBASE, SEQUELIZE } from './dialect-rules-node.js';
|
|
3
3
|
export function dialectOf(file, text) {
|
|
4
4
|
const path = file.split('\\').join('/').toLowerCase();
|
|
@@ -17,6 +17,10 @@ export function dialectOf(file, text) {
|
|
|
17
17
|
// strings passed to queryRunner.query(). The SQL is right there and perfectly readable; only the
|
|
18
18
|
// wrapper was unfamiliar, so every TypeORM project was told Tewip could not read its migrations
|
|
19
19
|
// at all - and on a Node backend that is most of them.
|
|
20
|
+
// Laravel, before the generic checks: its migrations are PHP and would otherwise fall through
|
|
21
|
+
// to 'unknown'. firefly-iii has 61 of them and Tewip could not read a word.
|
|
22
|
+
if (/Illuminate.Database.Migrations.Migration|\bSchema::(?:table|create|drop|dropIfExists|connection)\s*\(/.test(text))
|
|
23
|
+
return 'laravel';
|
|
20
24
|
if (/\bMigrationInterface\b|\bqueryRunner\.query\s*\(/.test(text))
|
|
21
25
|
return 'typeorm';
|
|
22
26
|
if (/\bqueryInterface\.\w+\s*\(/.test(text))
|
|
@@ -40,6 +44,7 @@ const BY_DIALECT = {
|
|
|
40
44
|
knex: KNEX,
|
|
41
45
|
efcore: EFCORE,
|
|
42
46
|
liquibase: LIQUIBASE,
|
|
47
|
+
laravel: LARAVEL,
|
|
43
48
|
};
|
|
44
49
|
/** Findings from a framework migration, with the line each one sits on. */
|
|
45
50
|
export function readFramework(dialect, text) {
|
|
@@ -111,6 +116,8 @@ export function forwardOnly(dialect, text) {
|
|
|
111
116
|
(dialect === 'sequelize' && /^\s*down\s*:/.test(line)) ||
|
|
112
117
|
(dialect === 'knex' && /^\s*(?:exports\.down|module\.exports\.down)\s*=/.test(line)) ||
|
|
113
118
|
(dialect === 'efcore' && /^\s*protected\s+override\s+void\s+Down\s*\(/.test(line)) ||
|
|
119
|
+
// Laravel's rollback, which in real files often comes *before* up().
|
|
120
|
+
(dialect === 'laravel' && /^\s*(?:public\s+)?function\s+down\s*\(/.test(line)) ||
|
|
114
121
|
// Liquibase puts the reverse inside the change itself rather than in a second method.
|
|
115
122
|
(dialect === 'liquibase' && /^\s*<rollback\b/.test(line));
|
|
116
123
|
if (down) {
|
|
@@ -13,8 +13,12 @@ import { dirname, resolve } from 'node:path';
|
|
|
13
13
|
import { changeSummary } from '../quality/grounding.js';
|
|
14
14
|
import { dialectOf, forwardOnly, irreversible, readFramework } from './frameworks.js';
|
|
15
15
|
import { readSql } from './statements.js';
|
|
16
|
-
const MIGRATION = /(^|\/)(migrations?|db\/migrate|prisma\/migrations|alembic|flyway|sql)\//i;
|
|
17
|
-
|
|
16
|
+
const MIGRATION = /(^|\/)(migrations?|migration|db\/migrate|prisma\/migrations|alembic|flyway|changelogs?|sql)\//i;
|
|
17
|
+
// A changelog is often not in a directory that says so. OpenMRS keeps 44 Liquibase changelogs in
|
|
18
|
+
// `api/src/main/resources/` named `liquibase-*.xml`, and the path filter saw none of them - so a
|
|
19
|
+
// real healthcare system with 44 migration files was told it had none. The name counts too.
|
|
20
|
+
const NAMED_MIGRATION = /(^|\/)(liquibase|changelog)[\w.-]*\.(xml|ya?ml|json)$/i;
|
|
21
|
+
const SCHEMA_FILE = /\.(sql)$|(^|\/)(migrations?|migration|db\/migrate)\/.*\.(py|rb|js|ts|php|cs)$/i;
|
|
18
22
|
/** The schema files in this change. */
|
|
19
23
|
export function schemaFiles(cmd) {
|
|
20
24
|
const named = cmd.files !== undefined && cmd.files.length > 0;
|
|
@@ -24,7 +28,7 @@ export function schemaFiles(cmd) {
|
|
|
24
28
|
// Asking for a file by name overrides the path filter: the filter exists to find migrations in
|
|
25
29
|
// a diff of a hundred files, not to argue with somebody who pointed at one. A TypeORM migration
|
|
26
30
|
// 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)));
|
|
31
|
+
.filter((f) => (named || SCHEMA_FILE.test(f) || MIGRATION.test(f) || NAMED_MIGRATION.test(f)) && existsSync(resolve(cmd.root, f)));
|
|
28
32
|
}
|
|
29
33
|
/**
|
|
30
34
|
* Tables this migration creates itself.
|
|
@@ -70,7 +74,7 @@ export function readMigration(root, file) {
|
|
|
70
74
|
file,
|
|
71
75
|
dialect,
|
|
72
76
|
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, TypeORM, Sequelize, Knex, EF Core and Liquibase migrations',
|
|
77
|
+
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, Laravel, EF Core and Liquibase migrations',
|
|
74
78
|
irreversible: false,
|
|
75
79
|
};
|
|
76
80
|
}
|
|
@@ -37,6 +37,7 @@ export function lookAround(root, limit = 4000) {
|
|
|
37
37
|
hasWorkflows: existsSync(join(root, '.github', 'workflows')),
|
|
38
38
|
hasStandards: existsSync(join(root, '.tewip', 'standards')),
|
|
39
39
|
filesSeen: false,
|
|
40
|
+
stoppedEarly: false,
|
|
40
41
|
};
|
|
41
42
|
let seen = 0;
|
|
42
43
|
// A queue rather than recursion: every directory at one depth is read before any at the next.
|
|
@@ -53,8 +54,13 @@ export function lookAround(root, limit = 4000) {
|
|
|
53
54
|
continue;
|
|
54
55
|
}
|
|
55
56
|
for (const entry of entries) {
|
|
56
|
-
|
|
57
|
+
// Out of budget with entries still unread in *this* directory. Checking only the queue was
|
|
58
|
+
// not enough: a project whose files sit in one flat directory empties the queue before the
|
|
59
|
+
// budget runs out, so the look stopped short and did not know it.
|
|
60
|
+
if (seen >= limit) {
|
|
61
|
+
found.stoppedEarly = true;
|
|
57
62
|
break;
|
|
63
|
+
}
|
|
58
64
|
if (SKIP.has(entry) || (entry.startsWith('.') && entry !== '.github'))
|
|
59
65
|
continue;
|
|
60
66
|
const full = join(dir, entry);
|
|
@@ -78,7 +84,10 @@ export function lookAround(root, limit = 4000) {
|
|
|
78
84
|
found.perfReports.push(rel);
|
|
79
85
|
else if (/(k6|lighthouse|summary|lhr)[\w.-]*\.json$/i.test(entry) && info.size < 8_000_000)
|
|
80
86
|
found.perfReports.push(rel);
|
|
81
|
-
|
|
87
|
+
// A changelog is often not in a directory that says so: OpenMRS keeps 44 Liquibase files in
|
|
88
|
+
// `api/src/main/resources/` named `liquibase-*.xml`. The name counts as well as the path.
|
|
89
|
+
const inMigrations = /(^|\/)(migrations?|migration|db\/migrate|changelogs?)\//i.test(rel) ||
|
|
90
|
+
/(^|\/)(liquibase|changelog)[\w.-]*\.(xml|ya?ml|json)$/i.test(rel);
|
|
82
91
|
// Reading a file is only worth it for the two questions a name cannot answer: is this a
|
|
83
92
|
// test, and is this a migration. Both are cheap, and both are often wrong from the path.
|
|
84
93
|
const language = languageOf(entry);
|
|
@@ -101,6 +110,8 @@ export function lookAround(root, limit = 4000) {
|
|
|
101
110
|
}
|
|
102
111
|
}
|
|
103
112
|
}
|
|
113
|
+
if (seen >= limit && queue.length > 0)
|
|
114
|
+
found.stoppedEarly = true;
|
|
104
115
|
return found;
|
|
105
116
|
}
|
|
106
117
|
const total = (counts) => [...counts.values()].reduce((a, b) => a + b, 0);
|
|
@@ -139,19 +150,19 @@ export function suggest(found) {
|
|
|
139
150
|
if (tests > 0) {
|
|
140
151
|
out.push({
|
|
141
152
|
command: 'tewip vouch',
|
|
142
|
-
because: `${tests} test ${tests === 1 ? 'file' : 'files'} here (${named(found.tests)}). This reads them and names the ones that cannot fail - no model, a second or two.`,
|
|
153
|
+
because: `${found.stoppedEarly ? 'At least ' : ''}${tests} test ${tests === 1 ? 'file' : 'files'} here (${named(found.tests)}). This reads them and names the ones that cannot fail - no model, a second or two.`,
|
|
143
154
|
});
|
|
144
155
|
}
|
|
145
156
|
if (migrations > 0) {
|
|
146
157
|
out.push({
|
|
147
158
|
command: 'tewip schema --diff main...HEAD',
|
|
148
|
-
because: `${migrations} ${migrations === 1 ? 'migration' : 'migrations'} here (${named(found.migrations)}). This says what a change would do to a live database before it runs.`,
|
|
159
|
+
because: `${found.stoppedEarly ? 'At least ' : ''}${migrations} ${migrations === 1 ? 'migration' : 'migrations'} here (${named(found.migrations)}). This says what a change would do to a live database before it runs.`,
|
|
149
160
|
});
|
|
150
161
|
}
|
|
151
162
|
if (found.markup > 0) {
|
|
152
163
|
out.push({
|
|
153
164
|
command: 'tewip access',
|
|
154
|
-
because: `${found.markup} markup ${found.markup === 1 ? 'file' : 'files'} here. This names who cannot use the interface, against the WCAG criterion a regulator would cite.`,
|
|
165
|
+
because: `${found.stoppedEarly ? 'At least ' : ''}${found.markup} markup ${found.markup === 1 ? 'file' : 'files'} here. This names who cannot use the interface, against the WCAG criterion a regulator would cite.`,
|
|
155
166
|
});
|
|
156
167
|
}
|
|
157
168
|
for (const report of found.perfReports.slice(0, 1)) {
|
|
@@ -187,6 +198,11 @@ export function renderStart(found, root) {
|
|
|
187
198
|
}
|
|
188
199
|
lines.push('## What it will not do here', '');
|
|
189
200
|
const cannot = [];
|
|
201
|
+
if (found.stoppedEarly) {
|
|
202
|
+
cannot.push('This project is larger than Tewip looks at from here, so the counts above are **at least** ' +
|
|
203
|
+
'that many rather than exactly. The commands themselves read everything; only this first ' +
|
|
204
|
+
'glance is bounded, so that it stays instant.');
|
|
205
|
+
}
|
|
190
206
|
if (total(found.tests) === 0)
|
|
191
207
|
cannot.push('No test files were found, so nothing can be said about whether your tests could fail or would catch a bug.');
|
|
192
208
|
if (total(found.migrations) === 0)
|
|
@@ -216,9 +216,15 @@ const stripLines = (body, lines) => body
|
|
|
216
216
|
.join(String.fromCharCode(10));
|
|
217
217
|
/** The first line that does something, for the report to quote. */
|
|
218
218
|
function firstRealLine(body) {
|
|
219
|
-
const
|
|
219
|
+
const lines = body
|
|
220
220
|
.split(String.fromCharCode(10))
|
|
221
221
|
.map((l) => l.trim())
|
|
222
|
-
.
|
|
223
|
-
|
|
222
|
+
.filter((l) => l !== '' && l !== '{' && l !== '}');
|
|
223
|
+
// A comment is not the evidence. Saleor's tests are written `# given` / `# when / then`, so the
|
|
224
|
+
// quoted evidence for 166 real findings was the single character "#", which tells a reader
|
|
225
|
+
// nothing about the test it is accusing. The line that does something is the evidence.
|
|
226
|
+
// Nor is a line with nothing in it but punctuation: a lone quote closing a docstring, a stray
|
|
227
|
+
// bracket. Evidence has to be something a reader can recognise as the test doing work.
|
|
228
|
+
const code = lines.find((l) => !/^(#|\/\/|\/\*|\*|"""|'''|--)/.test(l) && /[A-Za-z0-9]/.test(l));
|
|
229
|
+
return (code ?? lines[0] ?? '').slice(0, 120);
|
|
224
230
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.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": [
|