tewip-ai 0.50.0 → 0.52.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/access/checks.js +39 -56
- package/dist/engine/access/element.js +68 -0
- package/dist/engine/access/wcag.js +15 -0
- package/dist/engine/schema/dialect-rules-node.js +27 -2
- package/dist/engine/schema/frameworks.js +38 -16
- package/dist/engine/schema/migration-files.js +79 -0
- package/dist/engine/schema/schema-command.js +2 -6
- package/dist/engine/start/start-command.js +3 -2
- package/dist/engine/vouch/assertions.js +7 -0
- package/dist/engine/vouch/vouch.js +33 -4
- package/package.json +4 -2
|
@@ -6,57 +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
|
-
|
|
10
|
-
const INTERACTIVE = new Set(['a', 'button', 'input', 'select', 'textarea', 'summary', 'details']);
|
|
11
|
-
/** `role="presentation"` and friends: the author has said this is decoration. */
|
|
12
|
-
function decorative(t) {
|
|
13
|
-
return t.attrs['aria-hidden'] === 'true' || t.attrs.role === 'presentation' || t.attrs.role === 'none';
|
|
14
|
-
}
|
|
15
|
-
function show(t) {
|
|
16
|
-
const keep = ['src', 'href', 'type', 'name', 'id', 'class', 'role'];
|
|
17
|
-
const shown = keep
|
|
18
|
-
.filter((k) => typeof t.attrs[k] === 'string' && t.attrs[k] !== '')
|
|
19
|
-
.slice(0, 2)
|
|
20
|
-
.map((k) => ` ${k}="${t.attrs[k].slice(0, 40)}"`)
|
|
21
|
-
.join('');
|
|
22
|
-
return `<${t.name}${shown}${t.spread ? ' {...}' : ''}>`;
|
|
23
|
-
}
|
|
24
|
-
function at(t, file) {
|
|
25
|
-
return { file, line: t.line, element: show(t) };
|
|
26
|
-
}
|
|
27
|
-
/** Every real `aria-*` attribute. A name outside this set is a typo, and a typo does nothing at all. */
|
|
28
|
-
const ARIA = new Set([
|
|
29
|
-
'aria-activedescendant', 'aria-atomic', 'aria-autocomplete', 'aria-braillelabel', 'aria-brailleroledescription',
|
|
30
|
-
'aria-busy', 'aria-checked', 'aria-colcount', 'aria-colindex', 'aria-colindextext', 'aria-colspan',
|
|
31
|
-
'aria-controls', 'aria-current', 'aria-describedby', 'aria-description', 'aria-details', 'aria-disabled',
|
|
32
|
-
'aria-dropeffect', 'aria-errormessage', 'aria-expanded', 'aria-flowto', 'aria-grabbed', 'aria-haspopup',
|
|
33
|
-
'aria-hidden', 'aria-invalid', 'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-level', 'aria-live',
|
|
34
|
-
'aria-modal', 'aria-multiline', 'aria-multiselectable', 'aria-orientation', 'aria-owns', 'aria-placeholder',
|
|
35
|
-
'aria-posinset', 'aria-pressed', 'aria-readonly', 'aria-relevant', 'aria-required', 'aria-roledescription',
|
|
36
|
-
'aria-rowcount', 'aria-rowindex', 'aria-rowindextext', 'aria-rowspan', 'aria-selected', 'aria-setsize',
|
|
37
|
-
'aria-sort', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow', 'aria-valuetext',
|
|
38
|
-
]);
|
|
39
|
-
/** A click handler, in any of the four dialects this scanner reads. */
|
|
40
|
-
function clickHandler(t) {
|
|
41
|
-
return ['onclick', '@click', 'v-on:click', 'on:click'].some((a) => a in t.attrs || t.dynamic.has(a));
|
|
42
|
-
}
|
|
43
|
-
function keyHandler(t) {
|
|
44
|
-
return Object.keys(t.attrs).concat([...t.dynamic]).some((a) => /key(down|up|press)/i.test(a));
|
|
45
|
-
}
|
|
46
|
-
function tabIndexOf(t) {
|
|
47
|
-
const raw = t.attrs.tabindex ?? t.attrs.tabIndex;
|
|
48
|
-
if (raw === undefined)
|
|
49
|
-
return null;
|
|
50
|
-
const n = Number(raw);
|
|
51
|
-
return Number.isFinite(n) ? n : null;
|
|
52
|
-
}
|
|
53
|
-
/**
|
|
54
|
-
* Fields that take typing, and therefore need a label. Buttons and hidden fields are excluded: a
|
|
55
|
-
* submit button is named by its value, and a hidden field is not announced at all.
|
|
56
|
-
*/
|
|
57
|
-
const UNLABELLED_TYPES = new Set(['hidden', 'submit', 'reset', 'button', 'image']);
|
|
9
|
+
import { ARIA, INTERACTIVE, UNLABELLED_TYPES, at, clickHandler, decorative, hiddenFromEveryone, keyHandler, show, tabIndexOf } from './element.js';
|
|
58
10
|
/** Checks that look at one element at a time. */
|
|
59
|
-
function perElement(t, file, labelFor, openLabels, ancestorKey) {
|
|
11
|
+
function perElement(t, file, labelFor, openLabels, ancestorKey, namedByComponent = new Set()) {
|
|
60
12
|
const out = [];
|
|
61
13
|
const place = at(t, file);
|
|
62
14
|
const name = labelledBy(t);
|
|
@@ -78,16 +30,33 @@ function perElement(t, file, labelFor, openLabels, ancestorKey) {
|
|
|
78
30
|
next: 'add alt="..." describing what the image conveys, or alt="" if it conveys nothing' });
|
|
79
31
|
}
|
|
80
32
|
}
|
|
81
|
-
|
|
33
|
+
// A <select> and a <textarea> need a name exactly as much as an <input> does, and this checked
|
|
34
|
+
// only <input>. A screen reader announces an unnamed select as "combo box" and stops - the user is
|
|
35
|
+
// told there is a choice to make and nothing about what it decides. Found in Phase 43 by putting a
|
|
36
|
+
// deliberately broken React component in front of the scanner: it caught the bare <img>, <input>,
|
|
37
|
+
// <button>, <a> and click-only <div>, and walked straight past <select>. `select-name` was already
|
|
38
|
+
// in the axe rule table and already in INTERACTIVE; only this line had never been widened.
|
|
39
|
+
const NEEDS_NAME = { input: 'input-label', select: 'select-name', textarea: 'label' };
|
|
40
|
+
const nameRule = NEEDS_NAME[t.name];
|
|
41
|
+
// `!t.component` matters as much as the element name. React names its components with a capital,
|
|
42
|
+
// so PatternFly's `<Select>` and `<Input>` lowercase to the same string as the HTML elements - and
|
|
43
|
+
// this check, unlike `image-alt` beside it, never asked. Keycloak's admin UI is built on PatternFly
|
|
44
|
+
// and `<Select onOpenChange={...}>` was reported as an unnamed combo box; what that component
|
|
45
|
+
// renders, and whether it labels itself, is not in this file and cannot be read from it.
|
|
46
|
+
if (nameRule && !t.component && !hiddenFromEveryone(t) && (t.name !== 'input' || !UNLABELLED_TYPES.has(t.attrs.type ?? 'text'))) {
|
|
82
47
|
const linked = typeof t.attrs.id === 'string' && labelFor.has(t.attrs.id);
|
|
83
48
|
if (!name.named && !linked && openLabels === 0) {
|
|
84
|
-
if (name.unknown || t.dynamic.has('id')) {
|
|
85
|
-
out.push({ rule:
|
|
86
|
-
why:
|
|
49
|
+
if (name.unknown || t.dynamic.has('id') || t.dynamic.has('style') || (typeof t.attrs.id === 'string' && namedByComponent.has(t.attrs.id))) {
|
|
50
|
+
out.push({ rule: nameRule, confidence: 'ask-a-person', ...place,
|
|
51
|
+
why: typeof t.attrs.id === 'string' && namedByComponent.has(t.attrs.id)
|
|
52
|
+
? `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`
|
|
53
|
+
: t.dynamic.has('style')
|
|
54
|
+
? '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'
|
|
55
|
+
: 'no label, aria-label or wrapping <label> can be read here, and part of the element is an expression',
|
|
87
56
|
next: 'confirm the field is labelled at runtime; if not, add aria-label or a <label for>' });
|
|
88
57
|
}
|
|
89
58
|
else {
|
|
90
|
-
out.push({ rule:
|
|
59
|
+
out.push({ rule: nameRule, confidence: 'barrier', ...place,
|
|
91
60
|
why: 'no aria-label, no title, no id matching a <label for>, and not inside a <label>',
|
|
92
61
|
next: 'wrap it in a <label>, or give it an id and point a <label for> at it' });
|
|
93
62
|
}
|
|
@@ -152,6 +121,20 @@ function perElement(t, file, labelFor, openLabels, ancestorKey) {
|
|
|
152
121
|
export function checkFile(source, all, file, isDocument = false) {
|
|
153
122
|
const out = [];
|
|
154
123
|
const labelFor = new Set(all.filter((t) => t.name === 'label' && t.attrs.for).map((t) => t.attrs.for));
|
|
124
|
+
// Ids that a *component* names in one of its props. A design system labels a field by being told
|
|
125
|
+
// which field it is for - PatternFly's `<FormGroup label={...} fieldId="x">` renders `<label for="x">`
|
|
126
|
+
// around its children - and that label exists only in the component's own source, not here.
|
|
127
|
+
// Keycloak's admin UI is built this way throughout, so a control inside one is not an unnamed
|
|
128
|
+
// control; it is a control this file cannot answer for, which is a different verdict.
|
|
129
|
+
const namedByComponent = new Set();
|
|
130
|
+
for (const t of all) {
|
|
131
|
+
if (!t.component)
|
|
132
|
+
continue;
|
|
133
|
+
for (const [key, value] of Object.entries(t.attrs)) {
|
|
134
|
+
if (typeof value === 'string' && value !== '' && /(^|[a-z])(id|for)$/i.test(key))
|
|
135
|
+
namedByComponent.add(value);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
155
138
|
const seenIds = new Set();
|
|
156
139
|
let openLabels = 0;
|
|
157
140
|
let interactiveDepth = 0;
|
|
@@ -170,7 +153,7 @@ export function checkFile(source, all, file, isDocument = false) {
|
|
|
170
153
|
open.length = from;
|
|
171
154
|
continue;
|
|
172
155
|
}
|
|
173
|
-
out.push(...perElement(t, file, labelFor, Math.max(openLabels - (t.name === 'label' ? 1 : 0), 0), open.some((o) => o.keys)));
|
|
156
|
+
out.push(...perElement(t, file, labelFor, Math.max(openLabels - (t.name === 'label' ? 1 : 0), 0), open.some((o) => o.keys), namedByComponent));
|
|
174
157
|
if (!t.selfClosing)
|
|
175
158
|
open.push({ name: t.name, keys: keyHandler(t) });
|
|
176
159
|
const id = t.attrs.id;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** Elements that already answer to the keyboard and already carry a role. */
|
|
2
|
+
export const INTERACTIVE = new Set(['a', 'button', 'input', 'select', 'textarea', 'summary', 'details']);
|
|
3
|
+
/** `role="presentation"` and friends: the author has said this is decoration. */
|
|
4
|
+
export function decorative(t) {
|
|
5
|
+
return t.attrs['aria-hidden'] === 'true' || t.attrs.role === 'presentation' || t.attrs.role === 'none';
|
|
6
|
+
}
|
|
7
|
+
export function show(t) {
|
|
8
|
+
const keep = ['src', 'href', 'type', 'name', 'id', 'class', 'role'];
|
|
9
|
+
const shown = keep
|
|
10
|
+
.filter((k) => typeof t.attrs[k] === 'string' && t.attrs[k] !== '')
|
|
11
|
+
.slice(0, 2)
|
|
12
|
+
.map((k) => ` ${k}="${t.attrs[k].slice(0, 40)}"`)
|
|
13
|
+
.join('');
|
|
14
|
+
return `<${t.name}${shown}${t.spread ? ' {...}' : ''}>`;
|
|
15
|
+
}
|
|
16
|
+
export function at(t, file) {
|
|
17
|
+
return { file, line: t.line, element: show(t) };
|
|
18
|
+
}
|
|
19
|
+
/** Every real `aria-*` attribute. A name outside this set is a typo, and a typo does nothing at all. */
|
|
20
|
+
export const ARIA = new Set([
|
|
21
|
+
'aria-activedescendant', 'aria-atomic', 'aria-autocomplete', 'aria-braillelabel', 'aria-brailleroledescription',
|
|
22
|
+
'aria-busy', 'aria-checked', 'aria-colcount', 'aria-colindex', 'aria-colindextext', 'aria-colspan',
|
|
23
|
+
'aria-controls', 'aria-current', 'aria-describedby', 'aria-description', 'aria-details', 'aria-disabled',
|
|
24
|
+
'aria-dropeffect', 'aria-errormessage', 'aria-expanded', 'aria-flowto', 'aria-grabbed', 'aria-haspopup',
|
|
25
|
+
'aria-hidden', 'aria-invalid', 'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-level', 'aria-live',
|
|
26
|
+
'aria-modal', 'aria-multiline', 'aria-multiselectable', 'aria-orientation', 'aria-owns', 'aria-placeholder',
|
|
27
|
+
'aria-posinset', 'aria-pressed', 'aria-readonly', 'aria-relevant', 'aria-required', 'aria-roledescription',
|
|
28
|
+
'aria-rowcount', 'aria-rowindex', 'aria-rowindextext', 'aria-rowspan', 'aria-selected', 'aria-setsize',
|
|
29
|
+
'aria-sort', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow', 'aria-valuetext',
|
|
30
|
+
]);
|
|
31
|
+
/** A click handler, in any of the four dialects this scanner reads. */
|
|
32
|
+
export function clickHandler(t) {
|
|
33
|
+
return ['onclick', '@click', 'v-on:click', 'on:click'].some((a) => a in t.attrs || t.dynamic.has(a));
|
|
34
|
+
}
|
|
35
|
+
export function keyHandler(t) {
|
|
36
|
+
return Object.keys(t.attrs).concat([...t.dynamic]).some((a) => /key(down|up|press)/i.test(a));
|
|
37
|
+
}
|
|
38
|
+
export function tabIndexOf(t) {
|
|
39
|
+
const raw = t.attrs.tabindex ?? t.attrs.tabIndex;
|
|
40
|
+
if (raw === undefined)
|
|
41
|
+
return null;
|
|
42
|
+
const n = Number(raw);
|
|
43
|
+
return Number.isFinite(n) ? n : null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Fields that take typing, and therefore need a label. Buttons and hidden fields are excluded: a
|
|
47
|
+
* submit button is named by its value, and a hidden field is not announced at all.
|
|
48
|
+
*/
|
|
49
|
+
export const UNLABELLED_TYPES = new Set(['hidden', 'submit', 'reset', 'button', 'image']);
|
|
50
|
+
/**
|
|
51
|
+
* Hidden from everybody, so no name is needed and none is missing.
|
|
52
|
+
*
|
|
53
|
+
* A file picker styled out of sight and clicked by a button beside it is the standard way to build
|
|
54
|
+
* an upload control, and Keycloak's `<input id="jarUpload" type="file" style={{display:"none"}}>` is
|
|
55
|
+
* exactly that. `display:none` takes an element out of the accessibility tree altogether: it is
|
|
56
|
+
* announced to nobody, so "it has no label" stops nobody either.
|
|
57
|
+
*
|
|
58
|
+
* When the style is a JSX expression its text is not kept, so whether it hides the element cannot be
|
|
59
|
+
* read. That is not a reason to call it a certain barrier - it is a reason to say so.
|
|
60
|
+
*/
|
|
61
|
+
export function hiddenFromEveryone(t) {
|
|
62
|
+
if ('hidden' in t.attrs && t.attrs.hidden !== 'false')
|
|
63
|
+
return true;
|
|
64
|
+
if (t.attrs['aria-hidden'] === 'true')
|
|
65
|
+
return true;
|
|
66
|
+
const style = typeof t.attrs.style === 'string' ? t.attrs.style : '';
|
|
67
|
+
return /display\s*:\s*none|visibility\s*:\s*hidden/i.test(style);
|
|
68
|
+
}
|
|
@@ -19,6 +19,21 @@ export const RULES = {
|
|
|
19
19
|
severity: 'blocker',
|
|
20
20
|
stops: 'a screen reader announces "edit text" with no way to know what to type',
|
|
21
21
|
},
|
|
22
|
+
// A select and a textarea are form controls like any other, and both were missing here. A rule the
|
|
23
|
+
// scanner can raise but this table does not describe renders as "WCAG 2.2, unrated" with no line
|
|
24
|
+
// saying who it stops - which is most of what makes an accessibility finding worth acting on.
|
|
25
|
+
'select-name': {
|
|
26
|
+
criterion: '4.1.2 Name, Role, Value',
|
|
27
|
+
level: 'A',
|
|
28
|
+
severity: 'blocker',
|
|
29
|
+
stops: 'a screen reader announces "combo box" and nothing else, so the user is told there is a choice but not what it decides',
|
|
30
|
+
},
|
|
31
|
+
'label': {
|
|
32
|
+
criterion: '3.3.2 Labels or Instructions',
|
|
33
|
+
level: 'A',
|
|
34
|
+
severity: 'blocker',
|
|
35
|
+
stops: 'a screen reader announces "edit text, multi line" with no way to know what to type',
|
|
36
|
+
},
|
|
22
37
|
'button-name': {
|
|
23
38
|
criterion: '4.1.2 Name, Role, Value',
|
|
24
39
|
level: 'A',
|
|
@@ -142,14 +142,31 @@ const EFCORE = [
|
|
|
142
142
|
*/
|
|
143
143
|
const XML_TABLE = String.raw `tableName\s*=\s*["']([\w.]+)["']`;
|
|
144
144
|
const LIQUIBASE = [
|
|
145
|
-
|
|
146
|
-
|
|
145
|
+
// Liquibase writes a dropped column two ways, and both of these rules are needed. The order they
|
|
146
|
+
// are tried in does not matter now that neither can match the other's shape.
|
|
147
|
+
//
|
|
148
|
+
// The nested form, where the columns are children of the element:
|
|
149
|
+
// <dropColumn tableName="CLIENT">
|
|
150
|
+
// <column name="ALLOWED_CLAIMS_MASK"/>
|
|
151
|
+
// </dropColumn>
|
|
152
|
+
//
|
|
153
|
+
// `(?:(?!\/>)[^>])*>` is the important part: it matches the opening tag only when that tag is not
|
|
154
|
+
// self-closing. Without it the rule matched a self-closing `<dropColumn ... />` and then went on
|
|
155
|
+
// looking for a `<column name=` for up to 300 characters, which in Keycloak's changelog found one
|
|
156
|
+
// three lines later inside an unrelated `<createTable>`. Tewip reported "drops CLIENT.REALM_ID"
|
|
157
|
+
// while quoting a line that says `columnName="ALLOWED_CLAIMS_MASK"` - a headline contradicted by
|
|
158
|
+
// its own evidence, and the wrong column to go looking for.
|
|
159
|
+
{
|
|
160
|
+
find: new RegExp(String.raw `<dropColumn(?:(?!\/>)[^>])*${XML_TABLE}(?:(?!\/>)[^>])*>[\s\S]{0,300}?<column[^>]*name\s*=\s*["']([\w.]+)["']`, 'i'),
|
|
147
161
|
risk: 'breaks-old-code',
|
|
148
162
|
what: (m) => `drops ${m[1]}.${m[2]}`,
|
|
149
163
|
instead: 'deploy code that no longer selects or writes the column, then drop it in a later changeSet',
|
|
150
164
|
table: (m) => m[1],
|
|
151
165
|
column: (m) => m[2],
|
|
152
166
|
},
|
|
167
|
+
// The attribute form. Both orders, because XML attributes have no required order and Keycloak
|
|
168
|
+
// writes `tableName` first - which the old rule, demanding `columnName` first, could never match.
|
|
169
|
+
// Sixty-seven dropped columns in that repository and this is the spelling nearly all of them use.
|
|
153
170
|
{
|
|
154
171
|
find: new RegExp(String.raw `<dropColumn[^>]*columnName\s*=\s*["']([\w.]+)["'][^>]*${XML_TABLE}`, 'i'),
|
|
155
172
|
risk: 'breaks-old-code',
|
|
@@ -158,6 +175,14 @@ const LIQUIBASE = [
|
|
|
158
175
|
table: (m) => m[2],
|
|
159
176
|
column: (m) => m[1],
|
|
160
177
|
},
|
|
178
|
+
{
|
|
179
|
+
find: new RegExp(String.raw `<dropColumn[^>]*${XML_TABLE}[^>]*columnName\s*=\s*["']([\w.]+)["']`, 'i'),
|
|
180
|
+
risk: 'breaks-old-code',
|
|
181
|
+
what: (m) => `drops ${m[1]}.${m[2]}`,
|
|
182
|
+
instead: 'deploy code that no longer selects or writes the column, then drop it in a later changeSet',
|
|
183
|
+
table: (m) => m[1],
|
|
184
|
+
column: (m) => m[2],
|
|
185
|
+
},
|
|
161
186
|
{
|
|
162
187
|
find: new RegExp(String.raw `<dropTable[^>]*${XML_TABLE}`, 'i'),
|
|
163
188
|
risk: 'destroys-data',
|
|
@@ -46,29 +46,51 @@ const BY_DIALECT = {
|
|
|
46
46
|
liquibase: LIQUIBASE,
|
|
47
47
|
laravel: LARAVEL,
|
|
48
48
|
};
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Findings from a framework migration, with the line each one sits on.
|
|
51
|
+
*
|
|
52
|
+
* Every occurrence, not the first. `find.exec(text)` returns a single match, so a migration that
|
|
53
|
+
* dropped five tables reported one of them - and the other four were not mentioned anywhere, not
|
|
54
|
+
* even as "and more". Keycloak's `jpa-changelog-26.0.0.xml` drops **eight** tables; Tewip named one.
|
|
55
|
+
* Under-reporting a destructive change is the worst direction to be wrong in, because the reader
|
|
56
|
+
* sees a finding, deals with it, and reasonably concludes that was all of them.
|
|
57
|
+
*
|
|
58
|
+
* Found in Phase 43 and it was never dialect-specific: Django, Rails, Alembic, Laravel, Sequelize,
|
|
59
|
+
* Knex and EF Core all ran through this same loop.
|
|
60
|
+
*/
|
|
50
61
|
export function readFramework(dialect, text) {
|
|
51
62
|
const lines = text.split(String.fromCharCode(10));
|
|
52
63
|
const out = [];
|
|
64
|
+
// One line can only hold one finding of a given kind, and two rules often describe the same
|
|
65
|
+
// statement - Liquibase writes `dropColumn` in an attribute form and a nested form, and both
|
|
66
|
+
// rules exist to read it. Keyed by line and risk so the same drop is not reported twice.
|
|
67
|
+
const seen = new Set();
|
|
53
68
|
for (const operation of BY_DIALECT[dialect]) {
|
|
54
|
-
const hit = operation.find.exec(text);
|
|
55
|
-
if (!hit)
|
|
56
|
-
continue;
|
|
57
69
|
if (operation.unless?.test(text))
|
|
58
70
|
continue;
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
line
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
// A fresh global copy: the rules are shared module-level objects, and a global regex carries
|
|
72
|
+
// `lastIndex` between calls, so reusing one would start the next file mid-way through.
|
|
73
|
+
const all = new RegExp(operation.find.source, operation.find.flags.replace('g', '') + 'g');
|
|
74
|
+
for (const hit of text.matchAll(all)) {
|
|
75
|
+
// The line the match starts on, so the reader can go straight to it.
|
|
76
|
+
const before = text.slice(0, hit.index ?? 0).split(String.fromCharCode(10)).length;
|
|
77
|
+
const key = `${before}:${operation.risk}`;
|
|
78
|
+
if (seen.has(key))
|
|
79
|
+
continue;
|
|
80
|
+
seen.add(key);
|
|
81
|
+
out.push({
|
|
82
|
+
statement: (lines[before - 1] ?? hit[0]).trim().slice(0, 300),
|
|
83
|
+
line: before,
|
|
84
|
+
risk: operation.risk,
|
|
85
|
+
what: operation.what(hit),
|
|
86
|
+
instead: operation.instead,
|
|
87
|
+
table: operation.table?.(hit) ?? '',
|
|
88
|
+
column: operation.column?.(hit) ?? '',
|
|
89
|
+
});
|
|
90
|
+
}
|
|
70
91
|
}
|
|
71
|
-
|
|
92
|
+
// In file order, because a reader works down the file rather than down a list of rules.
|
|
93
|
+
return out.sort((a, b) => a.line - b.line);
|
|
72
94
|
}
|
|
73
95
|
/** A migration that cannot be rolled back by running its own down step. */
|
|
74
96
|
export function irreversible(dialect, text) {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Which files in a project are migrations at all.
|
|
2
|
+
//
|
|
3
|
+
// Split out of schema-command.ts in Phase 44, when the corpus harness needed to ask the question
|
|
4
|
+
// across a whole checkout rather than across a diff and that file went past the 300-line rule.
|
|
5
|
+
// Keeping it in one place is the point: the alternative was a copy of these patterns in a script,
|
|
6
|
+
// and two definitions of "is this a migration" drift the moment one of them is fixed.
|
|
7
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
8
|
+
import { join, relative } from 'node:path';
|
|
9
|
+
// A directory called `migration/` says what is probably in it, not what every file in it is. This
|
|
10
|
+
// matched *anything* underneath one, so Keycloak's `docs/guides/migration/index.adoc` and its 113
|
|
11
|
+
// `migration/migrators/MigrateTo12_0_0.java` classes - application code that moves data between
|
|
12
|
+
// Keycloak versions, nothing to do with a schema file - were selected as migrations and then listed
|
|
13
|
+
// as ones Tewip could not read. 205 files chosen in order to read 89. A "could not read" list that
|
|
14
|
+
// is mostly documentation teaches the reader to skip the list.
|
|
15
|
+
//
|
|
16
|
+
// So the directory decides *where*, and the extension decides *whether it could hold DDL at all*.
|
|
17
|
+
const MIGRATION_DIR = /(^|\/)(migrations?|migration|db\/migrate|prisma\/migrations|alembic|flyway|changelogs?|sql)\//i;
|
|
18
|
+
const COULD_HOLD_DDL = /\.(sql|py|rb|js|mjs|cjs|ts|php|cs|xml|ya?ml|json|go|exs?)$/i;
|
|
19
|
+
// Flyway's Java migrations are real, and they are named the way Flyway requires: `V2__add_index.java`
|
|
20
|
+
// or `R__rebuild_views.java`. Keycloak's `MigrateTo12_0_0.java` is not one. Taking `.java` on the
|
|
21
|
+
// strength of its directory alone is what filled that list with source files.
|
|
22
|
+
const FLYWAY_JAVA = /(^|\/)[VR][\w.]*__[\w.-]+\.(java|kt|scala)$/;
|
|
23
|
+
export const MIGRATION = {
|
|
24
|
+
test: (path) => MIGRATION_DIR.test(path) && (COULD_HOLD_DDL.test(path) || FLYWAY_JAVA.test(path)),
|
|
25
|
+
};
|
|
26
|
+
// A changelog is often not in a directory that says so. OpenMRS keeps 44 Liquibase changelogs in
|
|
27
|
+
// `api/src/main/resources/` named `liquibase-*.xml`, and the path filter saw none of them - so a
|
|
28
|
+
// real healthcare system with 44 migration files was told it had none. The name counts too.
|
|
29
|
+
//
|
|
30
|
+
// And a changelog is named for what it is, but hardly ever *only* that. Phase 42 taught this filter
|
|
31
|
+
// to read `liquibase-update-to-latest.xml` and it still missed Keycloak's 83, every one called
|
|
32
|
+
// `jpa-changelog-<version>.xml`: the word had to *begin* the name. Prefixes are the norm once a
|
|
33
|
+
// project has more than one database. Letting a release-notes `CHANGELOG.xml` through costs nothing,
|
|
34
|
+
// because `dialectOf` still has to find `<databaseChangeLog` in it before it is read as a migration.
|
|
35
|
+
export const NAMED_MIGRATION = /(^|\/)[\w.-]*(liquibase|changelog)[\w.-]*\.(xml|ya?ml|json)$/i;
|
|
36
|
+
export const SCHEMA_FILE = /\.(sql)$|(^|\/)(migrations?|migration|db\/migrate)\/.*\.(py|rb|js|ts|php|cs)$/i;
|
|
37
|
+
/**
|
|
38
|
+
* Every migration in a whole repository, by the same filters a diff is read through.
|
|
39
|
+
*
|
|
40
|
+
* `schemaFiles` answers "which of these changed files are migrations", which is the question a pull
|
|
41
|
+
* request asks. "What migrations does this project have at all" is a different question, and the
|
|
42
|
+
* corpus harness needs it to re-derive a verified number across a pinned checkout.
|
|
43
|
+
*/
|
|
44
|
+
export function migrationsUnder(root, limit = 20_000) {
|
|
45
|
+
const skip = new Set(['node_modules', '.git', 'dist', 'build', 'target', 'vendor', '__pycache__', '.venv', 'venv', '.next', '.cache']);
|
|
46
|
+
const found = [];
|
|
47
|
+
const walk = (where, depth) => {
|
|
48
|
+
if (depth > 12 || found.length >= limit)
|
|
49
|
+
return;
|
|
50
|
+
let entries;
|
|
51
|
+
try {
|
|
52
|
+
entries = readdirSync(where);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
if (skip.has(entry) || entry.startsWith('.'))
|
|
59
|
+
continue;
|
|
60
|
+
const full = join(where, entry);
|
|
61
|
+
let info;
|
|
62
|
+
try {
|
|
63
|
+
info = statSync(full);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (info.isDirectory()) {
|
|
69
|
+
walk(full, depth + 1);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
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);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
walk(root, 0);
|
|
78
|
+
return found.sort();
|
|
79
|
+
}
|
|
@@ -13,12 +13,8 @@ import { dirname, resolve } from 'node:path';
|
|
|
13
13
|
import { changeSummary } from '../quality/grounding.js';
|
|
14
14
|
import { dialectOf, forwardOnly, irreversible, readFramework } from './frameworks.js';
|
|
15
15
|
import { readSql } from './statements.js';
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
// `api/src/main/resources/` named `liquibase-*.xml`, and the path filter saw none of them - so a
|
|
19
|
-
// real healthcare system with 44 migration files was told it had none. The name counts too.
|
|
20
|
-
const NAMED_MIGRATION = /(^|\/)(liquibase|changelog)[\w.-]*\.(xml|ya?ml|json)$/i;
|
|
21
|
-
const SCHEMA_FILE = /\.(sql)$|(^|\/)(migrations?|migration|db\/migrate)\/.*\.(py|rb|js|ts|php|cs)$/i;
|
|
16
|
+
import { MIGRATION, NAMED_MIGRATION, SCHEMA_FILE, migrationsUnder } from './migration-files.js';
|
|
17
|
+
export { migrationsUnder };
|
|
22
18
|
/** The schema files in this change. */
|
|
23
19
|
export function schemaFiles(cmd) {
|
|
24
20
|
const named = cmd.files !== undefined && cmd.files.length > 0;
|
|
@@ -85,9 +85,10 @@ export function lookAround(root, limit = 4000) {
|
|
|
85
85
|
else if (/(k6|lighthouse|summary|lhr)[\w.-]*\.json$/i.test(entry) && info.size < 8_000_000)
|
|
86
86
|
found.perfReports.push(rel);
|
|
87
87
|
// A changelog is often not in a directory that says so: OpenMRS keeps 44 Liquibase files in
|
|
88
|
-
// `api/src/main/resources/` named `liquibase-*.xml`. The name counts as well as the path
|
|
88
|
+
// `api/src/main/resources/` named `liquibase-*.xml`. The name counts as well as the path - and
|
|
89
|
+
// the word may sit anywhere in it, because Keycloak's 83 are `jpa-changelog-<version>.xml`.
|
|
89
90
|
const inMigrations = /(^|\/)(migrations?|migration|db\/migrate|changelogs?)\//i.test(rel) ||
|
|
90
|
-
/(^|\/)(liquibase|changelog)[\w.-]*\.(xml|ya?ml|json)$/i.test(rel);
|
|
91
|
+
/(^|\/)[\w.-]*(liquibase|changelog)[\w.-]*\.(xml|ya?ml|json)$/i.test(rel);
|
|
91
92
|
// Reading a file is only worth it for the two questions a name cannot answer: is this a
|
|
92
93
|
// test, and is this a migration. Both are cheap, and both are often wrong from the path.
|
|
93
94
|
const language = languageOf(entry);
|
|
@@ -30,6 +30,13 @@ const CHECKS = [
|
|
|
30
30
|
/\bpytest\s*\.\s*(raises|warns|approx|fail)\b/,
|
|
31
31
|
/\bnp\s*\.\s*testing\s*\.\s*assert/,
|
|
32
32
|
/\bassert_(frame|series|index)_equal\b/,
|
|
33
|
+
// A bare `fail(...)`, which is how JUnit, TestNG and Go spell "this must not be reached" once the
|
|
34
|
+
// assertion is statically imported. `Assert.fail(...)` was already read by the pattern below;
|
|
35
|
+
// `fail(...)` on its own was not, so OpenMRS's RefByUuidTest - whose only real check is a `fail()`
|
|
36
|
+
// in the catch, beside a deliberate `assertTrue(true)` in the try - was called a tautology that
|
|
37
|
+
// "no change to the code can falsify". Make `getRefByUuid` throw and it falsifies immediately.
|
|
38
|
+
// The leading class keeps `shouldFail(` and `canFail(` out, and the paren must come next.
|
|
39
|
+
/(^|[^\w.])(?:Assert(?:ions)?\s*\.\s*)?fail\s*\(/,
|
|
33
40
|
// Java, Kotlin, C#, Scala.
|
|
34
41
|
/\bAssert(ions)?\s*\./,
|
|
35
42
|
/\bassertThat\s*\(/,
|
|
@@ -50,12 +50,12 @@ export function vouchFile(file, text) {
|
|
|
50
50
|
const focus = [...text.matchAll(/\b(it|test|describe)\s*\.\s*only\s*\(|\bfdescribe\s*\(|\bfit\s*\(/g)].find((hit) => !ranges.some(([from, to]) => (hit.index ?? 0) > from && (hit.index ?? 0) < to));
|
|
51
51
|
return {
|
|
52
52
|
file,
|
|
53
|
-
judgements: tests.map((test) => judge(file, test, helpers)),
|
|
53
|
+
judgements: tests.map((test) => judge(file, test, helpers, language)),
|
|
54
54
|
unread: '',
|
|
55
55
|
focused: focus ? `${focus[0].trim()}…) is focused, so the rest of this file does not run` : '',
|
|
56
56
|
};
|
|
57
57
|
}
|
|
58
|
-
function judge(file, test, helpers) {
|
|
58
|
+
function judge(file, test, helpers, language) {
|
|
59
59
|
const at = { file, name: test.name, line: test.line };
|
|
60
60
|
if (test.skipped)
|
|
61
61
|
return { ...at, verdict: 'never-runs', evidence: test.name, why: MEANS['never-runs'] };
|
|
@@ -76,7 +76,7 @@ function judge(file, test, helpers) {
|
|
|
76
76
|
if (unfinished.length > 0 && !checksSomething(stripLines(body, unfinished))) {
|
|
77
77
|
return { ...at, verdict: 'unfinished-expectation', evidence: unfinished[0], why: MEANS['unfinished-expectation'] };
|
|
78
78
|
}
|
|
79
|
-
const swallowed = swallowsFailure(body, checks);
|
|
79
|
+
const swallowed = swallowsFailure(body, checks, language);
|
|
80
80
|
if (swallowed)
|
|
81
81
|
return { ...at, verdict: 'swallows-failure', evidence: swallowed, why: MEANS['swallows-failure'] };
|
|
82
82
|
if (checks)
|
|
@@ -129,15 +129,44 @@ function withoutText(body) {
|
|
|
129
129
|
}
|
|
130
130
|
return out;
|
|
131
131
|
}
|
|
132
|
+
/**
|
|
133
|
+
* On the JVM, the only catches that can swallow a failing assertion.
|
|
134
|
+
*
|
|
135
|
+
* `Assert.fail()` and every JUnit matcher throw `AssertionError`, which extends `Error`, which
|
|
136
|
+
* extends `Throwable` - and `Error` is **not** an `Exception`. So `catch (Exception e) { }` does not
|
|
137
|
+
* catch a failing assertion, and neither does `catch (NotFoundException e) { }`. Only these do.
|
|
138
|
+
*
|
|
139
|
+
* This is not a detail. It is the difference between the standard Java way of writing "I expect this
|
|
140
|
+
* to throw" -
|
|
141
|
+
*
|
|
142
|
+
* try { parse(bad); Assert.fail(); } catch (VerificationException expected) { }
|
|
143
|
+
*
|
|
144
|
+
* - which is a correct test that fails properly when `parse` stops throwing, and a test that really
|
|
145
|
+
* does discard its own verdict. Tewip could not tell them apart and called **166 correct Keycloak
|
|
146
|
+
* tests worthless**, every one of them this idiom. Telling a developer their working test is
|
|
147
|
+
* worthless is the one failure this project cannot afford, so the caught type is now read.
|
|
148
|
+
*
|
|
149
|
+
* C# is deliberately not here: NUnit, xUnit and MSTest all throw assertion failures that derive from
|
|
150
|
+
* `Exception`, so `catch (Exception)` really does swallow them there. Same shape, opposite answer.
|
|
151
|
+
*/
|
|
152
|
+
const JVM_SWALLOWS = /^(?:java\.lang\.)?(?:Throwable|Error|AssertionError)$/;
|
|
132
153
|
/**
|
|
133
154
|
* Everything inside a `try` whose `catch` discards what it caught. An assertion failure is an
|
|
134
155
|
* exception, so a test shaped this way reports success however the code behaves - and a `catch` that
|
|
135
156
|
* asserts, re-throws or fails the test is the legitimate version, which this leaves alone.
|
|
136
157
|
*/
|
|
137
|
-
function swallowsFailure(body, checks) {
|
|
158
|
+
function swallowsFailure(body, checks, language) {
|
|
138
159
|
if (!checks)
|
|
139
160
|
return ''; // nothing to swallow; judged as no-assertion instead
|
|
140
161
|
for (const hit of body.matchAll(/catch\s*(\([^)]*\))?\s*\{/g)) {
|
|
162
|
+
// On the JVM the caught type decides it, and nearly every type leaves the assertion alone.
|
|
163
|
+
if (language === 'jvm') {
|
|
164
|
+
const caught = /\(\s*(?:final\s+)?([\w.]+)/.exec(hit[1] ?? '');
|
|
165
|
+
// A multi-catch - `catch (A | B e)` - swallows only if one of its types does.
|
|
166
|
+
const types = (hit[1] ?? '').replace(/[()]/g, '').split('|').map((t) => t.trim().split(/\s+/)[0]);
|
|
167
|
+
if (!types.some((t) => JVM_SWALLOWS.test(t)) && !(caught && JVM_SWALLOWS.test(caught[1])))
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
141
170
|
const open = body.indexOf('{', hit.index ?? 0);
|
|
142
171
|
const handler = balanced(body, open);
|
|
143
172
|
if (handler === null)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.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": [
|
|
@@ -63,7 +63,9 @@
|
|
|
63
63
|
"typecheck": "tsc --noEmit",
|
|
64
64
|
"build": "tsc -p tsconfig.build.json",
|
|
65
65
|
"prepack": "npm run build",
|
|
66
|
-
"tewip": "node cli/tewip.ts"
|
|
66
|
+
"tewip": "node cli/tewip.ts",
|
|
67
|
+
"version": "node scripts/sync-version.ts && git add server.json",
|
|
68
|
+
"corpus": "node scripts/corpus.ts"
|
|
67
69
|
},
|
|
68
70
|
"license": "Apache-2.0",
|
|
69
71
|
"devDependencies": {
|