tewip-ai 0.56.0 → 0.58.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/dist/engine/schema/dialect-rules.js +7 -2
- package/dist/engine/schema/migration-files.js +25 -3
- package/dist/engine/schema/schema-command.js +5 -0
- package/dist/engine/vouch/assertions.js +38 -1
- package/dist/engine/vouch/find-tests.js +13 -2
- package/dist/engine/vouch/tests-in.js +34 -1
- package/package.json +1 -1
|
@@ -46,11 +46,16 @@ const DJANGO = [
|
|
|
46
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
47
|
},
|
|
48
48
|
{
|
|
49
|
-
|
|
49
|
+
// The model is named, for two reasons. A finding that says "adds an index or constraint" without
|
|
50
|
+
// saying to what is most of the way to useless, and `createdHere` - which stops an index on a
|
|
51
|
+
// table created moments earlier from counting as a lock - matches on the table name, so with no
|
|
52
|
+
// name it could never suppress anything here. Django writes it `AddIndex(model_name="order", ...)`.
|
|
53
|
+
find: /(?:AddIndex|AddConstraint)\s*\([\s\S]{0,120}?model_name\s*=\s*["']([\w]+)["']/i,
|
|
50
54
|
unless: /AddIndexConcurrently|AddConstraintNotValid|atomic\s*=\s*False/,
|
|
51
55
|
risk: 'locks-writes',
|
|
52
|
-
what: () =>
|
|
56
|
+
what: (m) => `adds an index or constraint to ${m[1]}, which Django builds with a lock unless told otherwise`,
|
|
53
57
|
instead: 'AddIndexConcurrently / AddConstraintNotValid from django.contrib.postgres.operations, with atomic = False on the migration',
|
|
58
|
+
table: (m) => m[1],
|
|
54
59
|
},
|
|
55
60
|
];
|
|
56
61
|
/** Rails migrations. */
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// across a whole checkout rather than across a diff and that file went past the 300-line rule.
|
|
5
5
|
// Keeping it in one place is the point: the alternative was a copy of these patterns in a script,
|
|
6
6
|
// and two definitions of "is this a migration" drift the moment one of them is fixed.
|
|
7
|
-
import { readdirSync, statSync } from 'node:fs';
|
|
7
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
8
|
+
import { dialectOf } from './frameworks.js';
|
|
8
9
|
import { join, relative } from 'node:path';
|
|
9
10
|
// A directory called `migration/` says what is probably in it, not what every file in it is. This
|
|
10
11
|
// matched *anything* underneath one, so Keycloak's `docs/guides/migration/index.adoc` and its 113
|
|
@@ -70,8 +71,29 @@ export function migrationsUnder(root, limit = 20_000) {
|
|
|
70
71
|
continue;
|
|
71
72
|
}
|
|
72
73
|
const rel = relative(root, full).replaceAll(String.fromCharCode(92), '/');
|
|
73
|
-
if (SCHEMA_FILE.test(rel)
|
|
74
|
-
|
|
74
|
+
if (!SCHEMA_FILE.test(rel) && !MIGRATION.test(rel) && !NAMED_MIGRATION.test(rel))
|
|
75
|
+
continue;
|
|
76
|
+
// And it has to look like one inside. Phase 44 stopped a directory called `migration/` from
|
|
77
|
+
// claiming every file under it by extension; discourse shows that was not enough. `lib/migration/
|
|
78
|
+
// helpers.rb` is a library module, `table_dropper.rb` is a class, and
|
|
79
|
+
// `migrations/converters/config/locales/migrations.en.yml` is a locale file - all three were
|
|
80
|
+
// selected, none could be read, and all three were reported as migrations Tewip could not read.
|
|
81
|
+
// 550 of discourse's 3,095, which is a "could not read" list that is mostly library code.
|
|
82
|
+
//
|
|
83
|
+
// `dialectOf` is the one definition of what a migration is written in, so it decides here too:
|
|
84
|
+
// a file whose dialect is unknown is not an unreadable migration, it is not a migration.
|
|
85
|
+
if (info.size > 2_000_000)
|
|
86
|
+
continue;
|
|
87
|
+
let text = '';
|
|
88
|
+
try {
|
|
89
|
+
text = readFileSync(full, 'utf8');
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (dialectOf(rel, text) === 'unknown')
|
|
95
|
+
continue;
|
|
96
|
+
found.push(rel);
|
|
75
97
|
}
|
|
76
98
|
};
|
|
77
99
|
walk(root, 0);
|
|
@@ -46,6 +46,11 @@ function createdHere(text) {
|
|
|
46
46
|
// EF Core puts the name on the line after the call, so these have to reach across a newline.
|
|
47
47
|
const FRAMEWORK_CREATES = [
|
|
48
48
|
/migrationBuilder\s*\.\s*CreateTable\s*\([\s\S]{0,80}?name\s*:\s*["']([\w$.]+)["']/gi,
|
|
49
|
+
// Django names the model rather than the table, and so does its AddIndex, so the two match each
|
|
50
|
+
// other. Saleor never exercises this - Django convention puts CreateModel and AddIndex in
|
|
51
|
+
// separate migrations - but every other dialect here has its create form registered and this
|
|
52
|
+
// one did not, which is the kind of gap that waits for a project with different habits.
|
|
53
|
+
/migrations\s*\.\s*CreateModel\s*\([\s\S]{0,60}?name\s*=\s*["']([\w]+)["']/gi,
|
|
49
54
|
/queryInterface\s*\.\s*createTable\s*\(\s*["']([\w$.]+)["']/gi,
|
|
50
55
|
/knex\s*\.\s*schema\s*\.\s*createTable\s*\(\s*["']([\w$.]+)["']/gi,
|
|
51
56
|
/Schema\s*::\s*create\s*\(\s*["']([\w$.]+)["']/gi,
|
|
@@ -68,6 +68,21 @@ const CHECKS = [
|
|
|
68
68
|
// in laravel/framework's SupportStrTest came back as "Tewip would not judge" because the
|
|
69
69
|
// `assertSame(...)` in them was being read as an unknown helper that might assert.
|
|
70
70
|
/\$this\s*->\s*assert/,
|
|
71
|
+
// Laravel's fluent assertions, which is what a Laravel HTTP test actually asserts with:
|
|
72
|
+
//
|
|
73
|
+
// $response = $this->get(route('api.v1.autocomplete.accounts'));
|
|
74
|
+
// $response->assertUnauthorized();
|
|
75
|
+
// $response->assertHeader('Content-Type', 'application/json');
|
|
76
|
+
//
|
|
77
|
+
// The pattern above only reads `$this->assert`, so every one of those was an unknown helper that
|
|
78
|
+
// might assert - and firefly-iii, a real finance application, came back **72% "Tewip would not
|
|
79
|
+
// judge"**. A capital after `assert` is what keeps this from matching an ordinary method: it is
|
|
80
|
+
// assertOk, assertStatus, assertJson, assertUnauthorized, never assertion() or asserts().
|
|
81
|
+
// `$response->`, not any `->`: Java's lambda arrow is also `->`, so the first version of this
|
|
82
|
+
// quietly moved thirteen Keycloak verdicts - `coseKey -> assertEcKey(coseKey, ...)` matched. It
|
|
83
|
+
// happened to be right about them, and that is not the point: a PHP fix had changed Java answers
|
|
84
|
+
// nobody had looked at. The corpus caught it on the next run, which is what it is for.
|
|
85
|
+
/\$\w+\s*->\s*assert[A-Z]\w*\s*\(/,
|
|
71
86
|
/\b(?:self|static|Assert)\s*::\s*assert/,
|
|
72
87
|
/\bexpect\s*\(.*\)\s*->\s*\w+/,
|
|
73
88
|
// Rust. A #[should_panic] test asserts by panicking, so it can fail without an assert in it.
|
|
@@ -90,7 +105,8 @@ export function checksSomething(text) {
|
|
|
90
105
|
*/
|
|
91
106
|
export function unfinishedExpectations(body) {
|
|
92
107
|
const found = [];
|
|
93
|
-
|
|
108
|
+
const lines = body.split(String.fromCharCode(10));
|
|
109
|
+
for (const [i, line] of lines.entries()) {
|
|
94
110
|
const text = line.trim();
|
|
95
111
|
if (!/^(await\s+)?expect(\.soft)?\s*\(/.test(text))
|
|
96
112
|
continue;
|
|
@@ -98,6 +114,27 @@ export function unfinishedExpectations(body) {
|
|
|
98
114
|
if (after === null)
|
|
99
115
|
continue; // the call spans more lines; judged as part of the whole body
|
|
100
116
|
const rest = after.trim();
|
|
117
|
+
// An expectation kept in a variable and finished on a later line. RSpec allows it, and
|
|
118
|
+
// discourse writes it:
|
|
119
|
+
//
|
|
120
|
+
// preview =
|
|
121
|
+
// expect(Onebox.preview(url).to_s)
|
|
122
|
+
// preview.to match(/start=120/)
|
|
123
|
+
//
|
|
124
|
+
// `expect(...)` returns the target and `.to` is a method on it, so this is a finished
|
|
125
|
+
// expectation that happens to span three lines. Reading only the line the `expect` is on called
|
|
126
|
+
// it unfinished - "an expectation was written but no matcher was called on it" - about a test
|
|
127
|
+
// that checks two things.
|
|
128
|
+
if (rest === '' || rest === ';') {
|
|
129
|
+
// Only the continuation form, because that is the only one that reaches here: a line reading
|
|
130
|
+
// `preview = expect(x)` does not start with `expect(` and the loop above never looks at it.
|
|
131
|
+
const named = /([A-Za-z_$][\w$]*)\s*=\s*$/.exec(lines[i - 1] ?? '');
|
|
132
|
+
// Built by concatenation on purpose. Written as a template literal, `\b` is a backspace
|
|
133
|
+
// character and `\s` is the letter s, so the pattern silently matched nothing.
|
|
134
|
+
const finished = new RegExp('(^|[^\\w$.])' + named?.[1] + '\\s*\\.\\s*(to|not_to|to_not)\\b');
|
|
135
|
+
if (named && finished.test(body))
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
101
138
|
if (rest === '' || rest === ';')
|
|
102
139
|
found.push(text);
|
|
103
140
|
// A matcher named but not called: `.toBeVisible;` or `.toBeVisible` at the end of a statement.
|
|
@@ -193,7 +193,9 @@ const DECLARES = {
|
|
|
193
193
|
rust: /#\s*\[\s*(?:test|tokio::test|async_std::test|rstest)\s*\]/g,
|
|
194
194
|
// PHPUnit accepts three markers and real suites use all of them: the testFoo naming convention
|
|
195
195
|
// (laravel/framework does this), the #[Test] attribute of PHPUnit 10, and the @test docblock.
|
|
196
|
-
|
|
196
|
+
// `private`/`protected` dropped: PHPUnit collects public methods only, and a private helper whose
|
|
197
|
+
// name happens to start with "test" is not one. See tests-in.ts for the production file this cost.
|
|
198
|
+
php: /(?:#\s*\[\s*Test[^\]]*\]|\*\s*@test\b|(?:^|[^\w$>])(?:public\s+)?(?:static\s+)?function\s+test\w*\s*\()/gm,
|
|
197
199
|
};
|
|
198
200
|
/** JUnit, Kotlin, Scala, Go, C#, PHP and Rust: an annotation or a naming convention, then a braced body. */
|
|
199
201
|
function bracedTests(language, text) {
|
|
@@ -281,6 +283,15 @@ function skipsAtTopLevel(body) {
|
|
|
281
283
|
if (lines.length === 0)
|
|
282
284
|
return false;
|
|
283
285
|
const base = Math.min(...lines.map((l) => l.length - l.trimStart().length));
|
|
284
|
-
|
|
286
|
+
// Not a `skip` carrying a trailing condition. Ruby writes a guard as a modifier -
|
|
287
|
+
//
|
|
288
|
+
// skip "suggested topic not found in results" if suggested_topic.nil?
|
|
289
|
+
//
|
|
290
|
+
// - and that test runs every time the condition is false, which is most of the time. The Go side
|
|
291
|
+
// has refused conditional skips since it was written; Ruby's was added in Phase 36 without it, and
|
|
292
|
+
// discourse has four tests that run perfectly well being called "never runs".
|
|
293
|
+
return lines.some((l) => l.length - l.trimStart().length === base &&
|
|
294
|
+
/^\s*(?:skip|pending)\b/.test(l) &&
|
|
295
|
+
!/\s(?:if|unless)\s/.test(l));
|
|
285
296
|
}
|
|
286
297
|
export { jsTests, pythonTests, rubyTests, bracedTests };
|
|
@@ -30,14 +30,47 @@ export function languageOf(file) {
|
|
|
30
30
|
return null;
|
|
31
31
|
}
|
|
32
32
|
/** Does this file look like tests at all? Vouching for a source file is a category error. */
|
|
33
|
+
/**
|
|
34
|
+
* Minified or vendored: a file nobody in this project wrote, and nobody is going to fix.
|
|
35
|
+
*
|
|
36
|
+
* firefly-iii ships `public/v1/lib/jquery-4.0.0.min.js`, and minified JavaScript is full of `test(`
|
|
37
|
+
* - it is how a regular expression is applied - so it read as a test file and was judged as one.
|
|
38
|
+
* Nothing useful can come of that: the author cannot act on it, and it is not their code.
|
|
39
|
+
*
|
|
40
|
+
* Length is the signal rather than the name, because plenty of vendored bundles are not called
|
|
41
|
+
* `.min.js`. A minified file is one enormous line; source is not.
|
|
42
|
+
*/
|
|
43
|
+
function minifiedOrVendored(file, text) {
|
|
44
|
+
if (/[.-]min\.(js|css|mjs)$/i.test(file))
|
|
45
|
+
return true;
|
|
46
|
+
if (/(^|[\/])(vendor|third[_-]?party|bundles?|dist)[\/]/i.test(file))
|
|
47
|
+
return true;
|
|
48
|
+
const lines = text.split(String.fromCharCode(10));
|
|
49
|
+
const longest = lines.reduce((most, line) => Math.max(most, line.length), 0);
|
|
50
|
+
return longest > 2000 && text.length / Math.max(lines.length, 1) > 400;
|
|
51
|
+
}
|
|
33
52
|
export function looksLikeTests(file, text) {
|
|
53
|
+
if (minifiedOrVendored(file, text))
|
|
54
|
+
return false;
|
|
34
55
|
const named = /(^|[\\/])(tests?|spec|specs|__tests__|e2e|it|integration)[\\/]|[._-](test|tests|spec|specs|it)\.[a-z]+$|(^|[\\/])test_[^\\/]+\.py$|_test\.(go|py|rb)$|Tests?\.(java|kt|cs|scala)$/i.test(file);
|
|
35
56
|
const declares = /\b(it|test|describe|Scenario)\s*\(|^\s*def\s+test_|@Test\b|\[(Test|Fact|Theory)\]|\bfunc\s+Test[A-Z]/m.test(text) ||
|
|
36
57
|
// Ruby, PHP and Rust, added in Phase 36. PHPUnit names a test `testSomething`, so the `test\s*\(`
|
|
37
58
|
// above never matched it, and Rust marks one with an attribute no pattern here knew. Both came
|
|
38
59
|
// back as "0 test(s) read in 0 file(s)", which reads like a clean answer and is not one.
|
|
39
60
|
/#\s*\[\s*(?:test|tokio::test|rstest)\s*\]/.test(text) ||
|
|
40
|
-
|
|
61
|
+
// `public`, or no modifier at all: PHPUnit runs public methods and nothing else. Without that,
|
|
62
|
+
// firefly-iii's `private function testing()` - a middleware helper asking whether the app is in
|
|
63
|
+
// the test environment - made `app/Http/Middleware/InterestingMessage.php` a test file, and the
|
|
64
|
+
// whole of that production class was judged as tests. Vouching for source code is a different
|
|
65
|
+
// question, and this file says so three lines further down.
|
|
66
|
+
// A PHP method called `test*` is not a PHPUnit test. firefly-iii has eight public ones in
|
|
67
|
+
// production controllers - `testRule`, `testNotification` - which are endpoints a user hits to
|
|
68
|
+
// try a rule or send a test notification, and every one of them made its controller a test file.
|
|
69
|
+
// What makes a PHPUnit test is the class it is in, so that is what is asked for: a TestCase to
|
|
70
|
+
// extend, or the attribute and annotation forms that need no base class.
|
|
71
|
+
(/#\s*\[\s*Test\b|\*\s*@test\b/.test(text) ||
|
|
72
|
+
(/(?:^|[^\w$>])(?:public\s+)?(?:static\s+)?function\s+test\w*\s*\(/m.test(text) &&
|
|
73
|
+
/\bextends\s+\w*TestCase\b|\buse\s+PHPUnit\{2}Framework/.test(text))) ||
|
|
41
74
|
/^\s*(?:RSpec\.)?(?:describe|context)\s+|^\s*(?:x|f)?it\s+["'].*\bdo\b/m.test(text);
|
|
42
75
|
return named || declares;
|
|
43
76
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.58.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": [
|