tewip-ai 0.53.0 → 0.55.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
@@ -1067,9 +1067,18 @@ that never run, and tests that compare no value at all. No model, no cost.
1067
1067
  It is also an MCP tool, so a coding agent can ask about the tests it just wrote before claiming a
1068
1068
  change is covered.
1069
1069
 
1070
- **Measured on 5,448 tests in 8 public projects across 5 languages** (express, axios, chalk, requests,
1071
- click, gorilla/mux, cobra, gson): 19 tests were called unable to fail, and **all 19 were correct** when
1072
- read against the files - one unconditional skip in psf/requests, eighteen `@Ignore` methods in gson.
1070
+ **Measured on 41,523 tests in six public projects** - Keycloak, OpenMRS, bitwarden/server, Saleor,
1071
+ GOV.UK Frontend and Firefly III - in Java, C#, Python, PHP and TypeScript. Every accusation was read
1072
+ against its source by hand, and the counts are kept in [`corpus/expected.json`](corpus/expected.json)
1073
+ against a pinned commit, re-derived by `npm run corpus` on every release.
1074
+
1075
+ **It has been badly wrong once, and that is worth saying plainly.** An earlier version of this
1076
+ paragraph reported 19 of 19 correct on 5,448 tests. Then Tewip was pointed at Keycloak and **called
1077
+ 166 correct tests worthless** - every one of them the standard Java way of writing "I expect this to
1078
+ throw", which `Assert.fail()` makes fail properly because `AssertionError` is not an `Exception`.
1079
+ The rule had never read the caught type. 918 unit tests passed before that and after it, which is
1080
+ why the corpus exists: the thing that matters is what Tewip says about other people's code, and
1081
+ nothing was measuring it.
1073
1082
 
1074
1083
  Two things it refuses to do, which is why the list is worth reading:
1075
1084
 
@@ -6,9 +6,9 @@
6
6
  // div that only answers to a mouse. The ones that need a rendered page (contrast, focus order,
7
7
  // whether alt text is any good) are not attempted here and are listed in NOT_CHECKED instead.
8
8
  import { inside, labelledBy } from './markup.js';
9
- import { ARIA, INTERACTIVE, UNLABELLED_TYPES, at, clickHandler, decorative, hiddenFromEveryone, keyHandler, show, tabIndexOf } from './element.js';
9
+ import { ARIA, INTERACTIVE, UNLABELLED_TYPES, at, clickHandler, decorative, hiddenFromEveryone, keyHandler, notRendered, show, tabIndexOf } from './element.js';
10
10
  /** Checks that look at one element at a time. */
11
- function perElement(t, file, labelFor, openLabels, ancestorKey, namedByComponent = new Set()) {
11
+ function perElement(t, file, labelFor, openLabels, ancestorKey, namedByComponent = new Set(), boundLabels = new Set()) {
12
12
  const out = [];
13
13
  const place = at(t, file);
14
14
  const name = labelledBy(t);
@@ -44,7 +44,8 @@ function perElement(t, file, labelFor, openLabels, ancestorKey, namedByComponent
44
44
  // and `<Select onOpenChange={...}>` was reported as an unnamed combo box; what that component
45
45
  // renders, and whether it labels itself, is not in this file and cannot be read from it.
46
46
  if (nameRule && !t.component && !hiddenFromEveryone(t) && (t.name !== 'input' || !UNLABELLED_TYPES.has(t.attrs.type ?? 'text'))) {
47
- const linked = typeof t.attrs.id === 'string' && labelFor.has(t.attrs.id);
47
+ const bound = ['asp-for', 'formControlName', 'v-model', 'data-valmsg-for'].some((key) => typeof t.attrs[key] === 'string' && boundLabels.has(`${key}=${t.attrs[key]}`));
48
+ const linked = bound || (typeof t.attrs.id === 'string' && labelFor.has(t.attrs.id));
48
49
  if (!name.named && !linked && openLabels === 0) {
49
50
  if (name.unknown || t.dynamic.has('id') || t.dynamic.has('style') || (typeof t.attrs.id === 'string' && namedByComponent.has(t.attrs.id))) {
50
51
  out.push({ rule: nameRule, confidence: 'ask-a-person', ...place,
@@ -73,7 +74,11 @@ function perElement(t, file, labelFor, openLabels, ancestorKey, namedByComponent
73
74
  why: `tabindex="${tabindex}" takes this element out of the document order and puts it before everything that has none`,
74
75
  next: 'use tabindex="0" and let the source order decide, or move the element in the markup' });
75
76
  }
76
- if (t.attrs['aria-hidden'] === 'true' && (INTERACTIVE.has(t.name) || (tabindex !== null && tabindex >= 0))) {
77
+ // `hidden` takes the element out of the tab order as well as out of the page, so aria-hidden on it
78
+ // hides nothing that was reachable. GOV.UK's service-navigation toggle is written
79
+ // `<button ... hidden aria-hidden="true">` and uncovered by JavaScript when the menu is enabled -
80
+ // progressive enhancement done properly, reported as a barrier.
81
+ if (t.attrs['aria-hidden'] === 'true' && !notRendered(t) && (INTERACTIVE.has(t.name) || (tabindex !== null && tabindex >= 0))) {
77
82
  out.push({ rule: 'aria-hidden-focusable', confidence: 'barrier', ...place,
78
83
  why: 'hidden from screen readers but still reachable by the tab key',
79
84
  next: 'also remove it from the tab order with tabindex="-1", or stop hiding it' });
@@ -121,6 +126,22 @@ function perElement(t, file, labelFor, openLabels, ancestorKey, namedByComponent
121
126
  export function checkFile(source, all, file, isDocument = false) {
122
127
  const out = [];
123
128
  const labelFor = new Set(all.filter((t) => t.name === 'label' && t.attrs.for).map((t) => t.attrs.for));
129
+ // A framework can bind a label to a field without either of them spelling `for` or `id`. ASP.NET
130
+ // Core Tag Helpers do it with the model property: `asp-for="Name"` on the <label> renders
131
+ // `for="Name"`, and the same on the <input> renders `id="Name"`. bitwarden/server's admin views
132
+ // are written this way throughout, and 111 correctly labelled fields were called unlabelled.
133
+ // The same shape as PatternFly's FormGroup in Phase 43: the label is real, it is just not here.
134
+ const BINDS = ['asp-for', 'formControlName', 'v-model', 'data-valmsg-for'];
135
+ const boundLabels = new Set();
136
+ for (const t of all) {
137
+ if (t.name !== 'label')
138
+ continue;
139
+ for (const key of BINDS) {
140
+ const value = t.attrs[key];
141
+ if (typeof value === 'string' && value !== '')
142
+ boundLabels.add(`${key}=${value}`);
143
+ }
144
+ }
124
145
  // Ids that a *component* names in one of its props. A design system labels a field by being told
125
146
  // which field it is for - PatternFly's `<FormGroup label={...} fieldId="x">` renders `<label for="x">`
126
147
  // around its children - and that label exists only in the component's own source, not here.
@@ -153,7 +174,7 @@ export function checkFile(source, all, file, isDocument = false) {
153
174
  open.length = from;
154
175
  continue;
155
176
  }
156
- out.push(...perElement(t, file, labelFor, Math.max(openLabels - (t.name === 'label' ? 1 : 0), 0), open.some((o) => o.keys), namedByComponent));
177
+ out.push(...perElement(t, file, labelFor, Math.max(openLabels - (t.name === 'label' ? 1 : 0), 0), open.some((o) => o.keys), namedByComponent, boundLabels));
157
178
  if (!t.selfClosing)
158
179
  open.push({ name: t.name, keys: keyHandler(t) });
159
180
  const id = t.attrs.id;
@@ -58,9 +58,22 @@ export const UNLABELLED_TYPES = new Set(['hidden', 'submit', 'reset', 'button',
58
58
  * When the style is a JSX expression its text is not kept, so whether it hides the element cannot be
59
59
  * read. That is not a reason to call it a certain barrier - it is a reason to say so.
60
60
  */
61
- export function hiddenFromEveryone(t) {
61
+ /**
62
+ * Not rendered at all, and therefore not in the tab order either.
63
+ *
64
+ * Separate from `hiddenFromEveryone` because `aria-hidden` hides an element from a screen reader
65
+ * while leaving it perfectly focusable - that gap is a real barrier and has its own rule. The
66
+ * `hidden` attribute and `display:none` close the gap: nothing can reach the element by any route.
67
+ */
68
+ export function notRendered(t) {
62
69
  if ('hidden' in t.attrs && t.attrs.hidden !== 'false')
63
70
  return true;
71
+ const style = typeof t.attrs.style === 'string' ? t.attrs.style : '';
72
+ return /display\s*:\s*none|visibility\s*:\s*hidden/i.test(style);
73
+ }
74
+ export function hiddenFromEveryone(t) {
75
+ if (notRendered(t))
76
+ return true;
64
77
  if (t.attrs['aria-hidden'] === 'true')
65
78
  return true;
66
79
  const style = typeof t.attrs.style === 'string' ? t.attrs.style : '';
@@ -31,6 +31,27 @@ const TAG = /<(\/?)([A-Za-z][A-Za-z0-9._:-]*)(?=[\s/>])((?:[^>"'{]|"[^"]*"|'[^']
31
31
  const ATTRIBUTE = /([@:#\[A-Za-z_][-A-Za-z0-9_:.@\]]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|\{((?:[^{}]|\{[^{}]*\})*)\}|([^\s"'<>`=]+)))?/g;
32
32
  /** `{...props}`, `{...rest}` - the thing that makes every other judgement about the element unsafe. */
33
33
  const SPREAD = /\{\s*\.\.\./;
34
+ /**
35
+ * A template language's version of a spread: an expression standing where an attribute *name*
36
+ * would go, rather than inside a value.
37
+ *
38
+ * GOV.UK's own input component is written this way - every attribute, `id` included, comes out of
39
+ * one macro call:
40
+ *
41
+ * <input
42
+ * {{- govukAttributes({ class: classNames, id: id, name: params.name, ... }) }}
43
+ *
44
+ * so after the expressions are blanked the element reads as a bare `<input>` with nothing on it,
45
+ * and the scanner called the UK government's accessible-by-obligation text input a certain
46
+ * barrier. It is not: it is an element this file cannot answer for, which is a different verdict.
47
+ *
48
+ * Quoted values are removed first, because `id="{{ params.id }}"` is a dynamic attribute - which
49
+ * is already handled, and separately - rather than attributes arriving from somewhere else.
50
+ */
51
+ function templateSpread(blob) {
52
+ const withoutValues = blob.replace(/"[^"]*"|'[^']*'/g, '""');
53
+ return /\{\{|\{%|<%|#{/.test(withoutValues);
54
+ }
34
55
  /**
35
56
  * Comments, and the bodies of script and style, hold things shaped like tags that are not tags.
36
57
  *
@@ -73,7 +94,7 @@ export const JS_LIKE = /\.(?:[cm]?[jt]sx?|vue|svelte|astro)$/i;
73
94
  function readAttributes(blob) {
74
95
  const attrs = {};
75
96
  const dynamic = new Set();
76
- const spread = SPREAD.test(blob);
97
+ const spread = SPREAD.test(blob) || templateSpread(blob);
77
98
  // A spread's contents would otherwise be read as attribute names: `{...props}` becomes `props`.
78
99
  const readable = blob.replace(/\{\s*\.\.\.(?:[^{}]|\{[^{}]*\})*\}/g, ' ');
79
100
  for (const m of readable.matchAll(ATTRIBUTE)) {
@@ -39,8 +39,32 @@ export function schemaFiles(cmd) {
39
39
  */
40
40
  function createdHere(text) {
41
41
  const names = new Set();
42
- for (const m of text.matchAll(/\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?"?([\w$.]+)"?/gi)) {
43
- names.add(m[1].toLowerCase().replace(/^.*\./, ''));
42
+ // The frameworks say "create this table" in their own words, and the suppression is worth nothing
43
+ // in a dialect it cannot read. Only SQL was read, so bitwarden/server's 456 EF Core migrations -
44
+ // which do exactly what Prisma does, a CreateTable followed by a CreateIndex for every relation -
45
+ // produced 647 "blocks writes" findings about tables that had not existed a statement earlier.
46
+ // EF Core puts the name on the line after the call, so these have to reach across a newline.
47
+ const FRAMEWORK_CREATES = [
48
+ /migrationBuilder\s*\.\s*CreateTable\s*\([\s\S]{0,80}?name\s*:\s*["']([\w$.]+)["']/gi,
49
+ /queryInterface\s*\.\s*createTable\s*\(\s*["']([\w$.]+)["']/gi,
50
+ /knex\s*\.\s*schema\s*\.\s*createTable\s*\(\s*["']([\w$.]+)["']/gi,
51
+ /Schema\s*::\s*create\s*\(\s*["']([\w$.]+)["']/gi,
52
+ /\bcreate_table\s+[:'"]([\w$.]+)/gi,
53
+ /\bop\s*\.\s*create_table\s*\(\s*["']([\w$.]+)["']/gi,
54
+ /<createTable[^>]*tableName\s*=\s*["']([\w$.]+)["']/gi,
55
+ ];
56
+ for (const pattern of FRAMEWORK_CREATES)
57
+ for (const m of text.matchAll(pattern))
58
+ names.add(m[1].toLowerCase().replace(/^.*\./, ''));
59
+ // Every dialect's way of quoting a name, because the suppression is useless in the one that does
60
+ // not match. This read PostgreSQL's "quoted" form and plain names, and not SQL Server's
61
+ // [dbo].[Grant] - which is standard T-SQL and what bitwarden/server is written in throughout. No
62
+ // table was ever recognised as freshly created there, so every index built on a table created two
63
+ // statements earlier was reported as blocking writes: 714 findings, nearly all of them free.
64
+ for (const m of text.matchAll(/\bCREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w$.\"\[\]`]+)/gi)) {
65
+ // `[dbo].[Grant]` and `"public"."grant"` and `grant` all have to arrive here as the same word,
66
+ // because that is the form the findings report a table in.
67
+ names.add(m[1].replace(/[\"\[\]`]/g, '').toLowerCase().replace(/^.*\./, ''));
44
68
  }
45
69
  return names;
46
70
  }
@@ -25,11 +25,33 @@ export function decide(evidence) {
25
25
  if (evidence.changedSource.length === 0) {
26
26
  checks.push({ question: 'Is the changed code tested?', standing: 'not-applicable', answer: 'this change touches no source file', decidedBy: 'the diff' });
27
27
  }
28
+ else if (evidence.covering.length === 0 && evidence.changedTests.length > 0) {
29
+ // Nothing *imports* the changed file, and the same change ships tests. Coverage here is followed
30
+ // through imports, which is a fact rather than a name match - and which cannot see a test that
31
+ // reaches the code through a framework's entry point. A Saleor test posts a GraphQL query and
32
+ // asserts the error it gets back; the resolver chain in between is never imported by name.
33
+ //
34
+ // Found in Phase 46 by running this verdict over six real merged Saleor pull requests. Four came
35
+ // back **Not ready**, and all four shipped a test for exactly the change they were making. A gate
36
+ // that says "not ready" to a correct pull request is uninstalled within a week, and it is the
37
+ // same overclaim as a false accusation, pointed at the change instead of at a test.
38
+ //
39
+ // So: cannot say. Which is this project's whole point, and this check had no way to say it.
40
+ checks.push({
41
+ question: 'Is the changed code tested?',
42
+ standing: 'not-checked',
43
+ answer: `no test imports or reaches ${evidence.changedSource.slice(0, 3).join(', ')}, but this change also ` +
44
+ `edits ${evidence.changedTests.length} test file(s). Coverage is followed through imports, which cannot see a test ` +
45
+ 'that reaches the code through a GraphQL resolver, an HTTP route or any other framework entry point - ' +
46
+ 'so whether those tests cover this is a question the imports cannot answer',
47
+ decidedBy: 'imports followed through the source, which is a fact rather than a name match',
48
+ });
49
+ }
28
50
  else if (evidence.covering.length === 0) {
29
51
  checks.push({
30
52
  question: 'Is the changed code tested?',
31
53
  standing: 'not-met',
32
- answer: `no test imports or reaches ${evidence.changedSource.slice(0, 3).join(', ')}, so nothing here could notice a mistake in it`,
54
+ answer: `no test imports or reaches ${evidence.changedSource.slice(0, 3).join(', ')}, and this change adds no test either, so nothing here could notice a mistake in it`,
33
55
  decidedBy: 'imports followed through the source, which is a fact rather than a name match',
34
56
  });
35
57
  }
@@ -50,7 +72,7 @@ export function decide(evidence) {
50
72
  question: 'Can the tests in this change fail?',
51
73
  standing: 'not-met',
52
74
  answer: `${evidence.cannotFail.length} of them cannot: ${evidence.cannotFail.slice(0, 2).map((t) => `${t.name} (${t.file}:${t.line})`).join(', ')}`,
53
- decidedBy: 'tewip vouch, 19 of 19 correct on 5,448 tests in 8 public projects',
75
+ decidedBy: 'tewip vouch, measured on 41,523 real tests in the six public projects in corpus/expected.json',
54
76
  });
55
77
  }
56
78
  else {
@@ -120,7 +142,13 @@ export function decide(evidence) {
120
142
  headline: broken.length > 0
121
143
  ? `Not ready: ${broken[0].answer}`
122
144
  : verdict === 'cannot-say'
123
- ? `Cannot say: ${essential.filter((c) => c.standing === 'not-checked').map((c) => c.question.toLowerCase().replace(/\?$/, '')).join(' and ')} could not be checked, so this is not an approval`
145
+ ? // Quoted, because the questions are questions: lowercasing them and joining with "and"
146
+ // produced "is anything failing and is the changed code tested could not be checked",
147
+ // which is the headline of the verdict this whole tool is named for.
148
+ `Cannot say: ${essential.filter((c) => c.standing === 'not-checked').length === 1 ? 'one question that matters' : 'two questions that matter'} here went unanswered - ${essential
149
+ .filter((c) => c.standing === 'not-checked')
150
+ .map((c) => `"${c.question}"`)
151
+ .join(' and ')} - so this is not an approval`
124
152
  : `Ready as far as ${checks.filter((c) => c.standing === 'met').length} check(s) can show${unknown.length > 0 ? `, with ${unknown.length} not checked` : ''}`,
125
153
  checks,
126
154
  notChecked: [
@@ -239,7 +239,14 @@ function bracedTests(language, text) {
239
239
  // An annotation disables a test outright. A `t.Skip()` may be conditional - cobra's
240
240
  // TestDeadcodeElimination skips on Windows and runs everywhere else - so it counts only when it
241
241
  // is unconditional, at the top level of the body.
242
- const skipped = /@(Disabled|Ignore)\b|\[\s*Ignore\b/.test(around) || skipsUnconditionally(body);
242
+ // xUnit - the most used .NET framework by a distance - has no separate attribute for this. It
243
+ // skips by giving the test attribute a reason: `[Fact(Skip = "...")]`, `[Theory(Skip = ...)]`.
244
+ // Only `[Ignore]` was read, which is NUnit and MSTest, so bitwarden/server's **43 skipped tests
245
+ // were reported as 1**. A team told their suite is sound while 43 of its tests never run is
246
+ // worse off than a team told nothing at all.
247
+ const skipped = /@(Disabled|Ignore)\b|\[\s*Ignore\b/.test(around) ||
248
+ /\[\s*\w*(?:Fact|Theory)\s*\([^)]*\bSkip\s*=/.test(around) ||
249
+ skipsUnconditionally(body);
243
250
  found.push({ name, line: lineAt(text, at), body, skipped, callback: '' });
244
251
  }
245
252
  return found;
@@ -253,7 +260,12 @@ function skipsUnconditionally(body) {
253
260
  let depth = 0;
254
261
  for (const line of body.split(String.fromCharCode(10))) {
255
262
  const text = line.trim();
256
- if (depth === 0 && /^\w+\s*\.\s*Skip(Now)?\s*\(/.test(text))
263
+ // `t.Skip(...)` in Go: a short receiver, and a whole statement with nothing chained onto it.
264
+ // `\w+\.Skip(` matched any identifier at all, so C#'s LINQ - `collections.Skip(1).ToList()`,
265
+ // which is how .NET pages a sequence - read as a test turning itself off. bitwarden/server has
266
+ // one, and it was the only never-runs Tewip reported there until xUnit's `[Fact(Skip = ...)]`
267
+ // was understood; the real number was 43 and the one it had was wrong.
268
+ if (depth === 0 && /^\w{1,3}\s*\.\s*Skip(f|Now)?\s*\([^)]*\)\s*;?\s*$/.test(text))
257
269
  return true;
258
270
  depth += (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length;
259
271
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.53.0",
3
+ "version": "0.55.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": [