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:
|
|
53
|
-
?
|
|
54
|
-
: t.
|
|
55
|
-
?
|
|
56
|
-
: '
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 [
|
|
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
|
-
|
|
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,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "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": [
|