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 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
- return jsLike ? stripped.replace(/`(?:[^`\\]|\\[\s\S])*`/g, blank) : stripped;
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
- const SCHEMA_FILE = /\.(sql)$|(^|\/)(migrations?|db\/migrate)\/.*\.(py|rb|js|ts)$/i;
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
- if (seen >= limit)
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
- const inMigrations = /(^|\/)(migrations?|db\/migrate|changelog)\//i.test(rel);
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 line = body
219
+ const lines = body
220
220
  .split(String.fromCharCode(10))
221
221
  .map((l) => l.trim())
222
- .find((l) => l !== '' && l !== '{' && l !== '}');
223
- return (line ?? '').slice(0, 120);
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.48.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": [