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.
@@ -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
- find: /AddIndex\(|AddConstraint\(/i,
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: () => 'adds an index or constraint, which Django builds with a lock unless told otherwise',
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) || MIGRATION.test(rel) || NAMED_MIGRATION.test(rel))
74
- found.push(rel);
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
- for (const line of body.split(String.fromCharCode(10))) {
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
- php: /(?:#\s*\[\s*Test[^\]]*\]|\*\s*@test\b|(?:public|protected|private)?\s*function\s+test\w*\s*\()/g,
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
- return lines.some((l) => l.length - l.trimStart().length === base && /^\s*(?:skip|pending)\b/.test(l));
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
- /\bfunction\s+test\w*\s*\(|#\s*\[\s*Test\b|\*\s*@test\b/.test(text) ||
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.56.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": [