tewip-ai 0.57.0 → 0.59.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.
@@ -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, notRendered, show, tabIndexOf } from './element.js';
9
+ import { ARIA, INTERACTIVE, UNLABELLED_TYPES, at, clickHandler, customElement, 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(), boundLabels = new Set()) {
11
+ function perElement(t, file, labelFor, openLabels, ancestorKey, namedByComponent = new Set(), boundLabels = new Set(), labelledByAComponent = false) {
12
12
  const out = [];
13
13
  const place = at(t, file);
14
14
  const name = labelledBy(t);
@@ -47,13 +47,15 @@ function perElement(t, file, labelFor, openLabels, ancestorKey, namedByComponent
47
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
48
  const linked = bound || (typeof t.attrs.id === 'string' && labelFor.has(t.attrs.id));
49
49
  if (!name.named && !linked && openLabels === 0) {
50
- if (name.unknown || t.dynamic.has('id') || t.dynamic.has('style') || (typeof t.attrs.id === 'string' && namedByComponent.has(t.attrs.id))) {
50
+ if (labelledByAComponent || name.unknown || t.dynamic.has('id') || t.dynamic.has('style') || (typeof t.attrs.id === 'string' && namedByComponent.has(t.attrs.id))) {
51
51
  out.push({ rule: nameRule, confidence: 'ask-a-person', ...place,
52
- why: typeof t.attrs.id === 'string' && namedByComponent.has(t.attrs.id)
53
- ? `no <label for> is written here, but a component in this file names id="${t.attrs.id}" in one of its props, so the label may be rendered there`
54
- : t.dynamic.has('style')
55
- ? 'no label can be read here, and its style is an expression, so whether this is displayed at all cannot be read from the markup'
56
- : 'no label, aria-label or wrapping <label> can be read here, and part of the element is an expression',
52
+ why: labelledByAComponent
53
+ ? 'no label is written on this control, but it sits inside a custom element that contains a label - a component that binds the two when it renders, which this file cannot show'
54
+ : typeof t.attrs.id === 'string' && namedByComponent.has(t.attrs.id)
55
+ ? `no <label for> is written here, but a component in this file names id="${t.attrs.id}" in one of its props, so the label may be rendered there`
56
+ : t.dynamic.has('style')
57
+ ? 'no label can be read here, and its style is an expression, so whether this is displayed at all cannot be read from the markup'
58
+ : 'no label, aria-label or wrapping <label> can be read here, and part of the element is an expression',
57
59
  next: 'confirm the field is labelled at runtime; if not, add aria-label or a <label for>' });
58
60
  }
59
61
  else {
@@ -87,7 +89,11 @@ function perElement(t, file, labelFor, openLabels, ancestorKey, namedByComponent
87
89
  // world is built - a backdrop div closes on click, and Escape closes it from the dialog itself.
88
90
  // WCAG 2.1.1 asks that the *function* be reachable from the keyboard, not that this particular
89
91
  // div be focusable. Excalidraw's Modal.tsx is exactly this shape, and calling it a blocker is wrong.
90
- if (clickHandler(t) && !INTERACTIVE.has(t.name) && !t.component && !t.attrs.role && tabindex === null && !keyHandler(t) && !ancestorKey) {
92
+ // `!customElement(t)` as well as `!t.component`: `<mat-checkbox (click)="toggle()">` is a checkbox
93
+ // that Angular renders with full keyboard handling, and `<mat-list-item>` likewise. Reading the
94
+ // markup alone they look like bare tags carrying a click. A `<tr (click)>` beside them in the same
95
+ // file is a genuine barrier, and still reported as one - table rows really are not focusable.
96
+ if (clickHandler(t) && !INTERACTIVE.has(t.name) && !t.component && !customElement(t) && !t.attrs.role && tabindex === null && !keyHandler(t) && !ancestorKey) {
91
97
  out.push({ rule: 'click-without-keyboard', confidence: 'barrier', ...place,
92
98
  why: 'it responds to a click, but it has no role, no tabindex and no key handler, so the keyboard cannot reach or fire it',
93
99
  next: 'use a <button>, or add role="button", tabindex="0" and a key handler' });
@@ -160,7 +166,28 @@ export function checkFile(source, all, file, isDocument = false) {
160
166
  let openLabels = 0;
161
167
  let interactiveDepth = 0;
162
168
  let lastHeading = 0;
163
- /** The elements currently open around this one, and whether each handles keys. */
169
+ /**
170
+ * The elements currently open around this one: whether each handles keys, whether it is a custom
171
+ * element, and whether a label has appeared inside it.
172
+ *
173
+ * A custom element does its own labelling and does it somewhere else. Angular Material writes
174
+ *
175
+ * <mat-form-field>
176
+ * <mat-label>Title</mat-label>
177
+ * <input matInput type="text">
178
+ * </mat-form-field>
179
+ *
180
+ * and `MatFormField` generates the id on the input and the `for` on the label when it renders.
181
+ * The label is real; it is simply not expressed here. Reading the markup alone, Tewip called 426
182
+ * inputs in **the Angular team's own accessibility-focused component library** unlabelled.
183
+ *
184
+ * The same family as PatternFly's `<FormGroup fieldId>` in Phase 43 and ASP.NET's `asp-for` in
185
+ * Phase 45. This is the third spelling: the component wraps the label and the control together.
186
+ *
187
+ * A dash in the name is what marks a custom element - no standard HTML element has one - and the
188
+ * label must actually appear inside it, so an ordinary `<my-card>` around a bare input is still a
189
+ * barrier rather than a shrug.
190
+ */
164
191
  const open = [];
165
192
  for (const [i, t] of all.entries()) {
166
193
  if (t.name === 'label')
@@ -174,9 +201,15 @@ export function checkFile(source, all, file, isDocument = false) {
174
201
  open.length = from;
175
202
  continue;
176
203
  }
177
- out.push(...perElement(t, file, labelFor, Math.max(openLabels - (t.name === 'label' ? 1 : 0), 0), open.some((o) => o.keys), namedByComponent, boundLabels));
204
+ // A label, or a component that is one by name - `<mat-label>`, `<ion-label>`, `<sl-label>`.
205
+ if (t.name === 'label' || /(^|-)labels?$/.test(t.name))
206
+ for (const o of open)
207
+ if (o.custom)
208
+ o.sawLabel = true;
209
+ const labelledByAComponent = open.some((o) => o.custom && o.sawLabel);
210
+ out.push(...perElement(t, file, labelFor, Math.max(openLabels - (t.name === 'label' ? 1 : 0), 0), open.some((o) => o.keys), namedByComponent, boundLabels, labelledByAComponent));
178
211
  if (!t.selfClosing)
179
- open.push({ name: t.name, keys: keyHandler(t) });
212
+ open.push({ name: t.name, keys: keyHandler(t), custom: t.name.includes('-'), sawLabel: false });
180
213
  const id = t.attrs.id;
181
214
  if (isDocument && id && !t.dynamic.has('id')) {
182
215
  if (seenIds.has(id)) {
@@ -29,8 +29,44 @@ export const ARIA = new Set([
29
29
  'aria-sort', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow', 'aria-valuetext',
30
30
  ]);
31
31
  /** A click handler, in any of the four dialects this scanner reads. */
32
+ /**
33
+ * A click handler, in any of the spellings the frameworks use.
34
+ *
35
+ * This was a list of four exact names, and two whole frameworks wrote theirs differently:
36
+ *
37
+ * - **Angular** binds events as `(click)="..."`. angular/components has **414** of them and Tewip
38
+ * saw none, so `click-without-keyboard` could never fire in any Angular project at all. The
39
+ * library reported zero, which looked like a clean result and was blindness.
40
+ * - **Vue** allows modifiers, so the attribute is `@click.stop` or `@click.prevent` rather than
41
+ * `@click`. nocodb has **455** of those.
42
+ *
43
+ * Svelte was already covered both ways - `on:click` and Svelte 5's `onclick` - which is why this
44
+ * only showed up when a third framework was finally pointed at.
45
+ *
46
+ * `keyHandler` below had no such problem because it matches on a substring; the trade is that this
47
+ * one has to name the shapes, so that `data-click-target` is not read as a handler.
48
+ */
49
+ // Angular's `(click)="go()"` reaches here as an attribute plainly named `click`, because the
50
+ // attribute reader strips the parentheses - which is also why `keyHandler` below already worked for
51
+ // Angular and this did not: it matches on a substring and this matched on an exact name. No standard
52
+ // HTML attribute is called `click`, so taking the bare form costs nothing.
53
+ const CLICK = /^(?:on)?(?:click|dblclick)$|^(?:@|v-on:|on:)(?:click|dblclick)(?:[.|][\w.|$-]*)?$/i;
54
+ /**
55
+ * A custom element: a component in markup that does not spell its name with a capital.
56
+ *
57
+ * `t.component` catches React's `<Select>` and `<Foo.Bar>`, and misses every web component and every
58
+ * Angular or Ionic element - `<mat-checkbox>`, `<mat-list-item>`, `<ion-button>`. A dash in the name
59
+ * is what the HTML spec reserves for them, and no standard element has one.
60
+ *
61
+ * It matters wherever Tewip is about to say something certain about an element whose behaviour is
62
+ * written somewhere else. `<mat-checkbox (click)="x()">` is a real checkbox with real keyboard
63
+ * handling once Angular renders it; reading the markup alone it looks like a bare tag with a click.
64
+ */
65
+ export function customElement(t) {
66
+ return t.name.includes('-');
67
+ }
32
68
  export function clickHandler(t) {
33
- return ['onclick', '@click', 'v-on:click', 'on:click'].some((a) => a in t.attrs || t.dynamic.has(a));
69
+ return [...Object.keys(t.attrs), ...t.dynamic].some((a) => CLICK.test(a));
34
70
  }
35
71
  export function keyHandler(t) {
36
72
  return Object.keys(t.attrs).concat([...t.dynamic]).some((a) => /key(down|up|press)/i.test(a));
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.57.0",
3
+ "version": "0.59.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": [