tewip-ai 0.49.0 → 0.51.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,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
- /** Elements that already answer to the keyboard and already carry a role. */
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
- if (t.name === 'input' && !UNLABELLED_TYPES.has(t.attrs.type ?? 'text')) {
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: 'input-label', confidence: 'ask-a-person', ...place,
86
- why: 'no label, aria-label or wrapping <label> can be read here, and part of the element is an expression',
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: 'input-label', confidence: 'barrier', ...place,
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
- find: new RegExp(String.raw `<dropColumn[^>]*${XML_TABLE}[\s\S]{0,300}?<column[^>]*name\s*=\s*["']([\w.]+)["']`, 'i'),
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
- /** Findings from a framework migration, with the line each one sits on. */
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
- // The line the match starts on, so the reader can go straight to it.
60
- const before = text.slice(0, hit.index ?? 0).split(String.fromCharCode(10)).length;
61
- out.push({
62
- statement: (lines[before - 1] ?? hit[0]).trim().slice(0, 300),
63
- line: before,
64
- risk: operation.risk,
65
- what: operation.what(hit),
66
- instead: operation.instead,
67
- table: operation.table?.(hit) ?? '',
68
- column: operation.column?.(hit) ?? '',
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
- return out;
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) {
@@ -13,8 +13,18 @@ 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
- const MIGRATION = /(^|\/)(migrations?|db\/migrate|prisma\/migrations|alembic|flyway|sql)\//i;
17
- const SCHEMA_FILE = /\.(sql)$|(^|\/)(migrations?|db\/migrate)\/.*\.(py|rb|js|ts)$/i;
16
+ const MIGRATION = /(^|\/)(migrations?|migration|db\/migrate|prisma\/migrations|alembic|flyway|changelogs?|sql)\//i;
17
+ // A changelog is often not in a directory that says so. OpenMRS keeps 44 Liquibase changelogs in
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
+ // A changelog is named for what it is, but hardly ever *only* that. Phase 42 taught this filter to
21
+ // read `liquibase-update-to-latest.xml` and it still missed Keycloak's 83 files, every one of them
22
+ // called `jpa-changelog-<version>.xml`: the name was required to *begin* with the word. Prefixes are
23
+ // the norm - a project with more than one database names each changelog after its module. So the
24
+ // word may sit anywhere in the name. Letting a release-notes `CHANGELOG.xml` through costs nothing,
25
+ // because `dialectOf` still has to find `<databaseChangeLog` in it before it is read as a migration.
26
+ const NAMED_MIGRATION = /(^|\/)[\w.-]*(liquibase|changelog)[\w.-]*\.(xml|ya?ml|json)$/i;
27
+ const SCHEMA_FILE = /\.(sql)$|(^|\/)(migrations?|migration|db\/migrate)\/.*\.(py|rb|js|ts|php|cs)$/i;
18
28
  /** The schema files in this change. */
19
29
  export function schemaFiles(cmd) {
20
30
  const named = cmd.files !== undefined && cmd.files.length > 0;
@@ -24,7 +34,7 @@ export function schemaFiles(cmd) {
24
34
  // Asking for a file by name overrides the path filter: the filter exists to find migrations in
25
35
  // a diff of a hundred files, not to argue with somebody who pointed at one. A TypeORM migration
26
36
  // sitting outside a migrations/ directory was being answered with "no migration in this change".
27
- .filter((f) => (named || SCHEMA_FILE.test(f) || MIGRATION.test(f)) && existsSync(resolve(cmd.root, f)));
37
+ .filter((f) => (named || SCHEMA_FILE.test(f) || MIGRATION.test(f) || NAMED_MIGRATION.test(f)) && existsSync(resolve(cmd.root, f)));
28
38
  }
29
39
  /**
30
40
  * Tables this migration creates itself.
@@ -37,6 +37,7 @@ export function lookAround(root, limit = 4000) {
37
37
  hasWorkflows: existsSync(join(root, '.github', 'workflows')),
38
38
  hasStandards: existsSync(join(root, '.tewip', 'standards')),
39
39
  filesSeen: false,
40
+ stoppedEarly: false,
40
41
  };
41
42
  let seen = 0;
42
43
  // A queue rather than recursion: every directory at one depth is read before any at the next.
@@ -53,8 +54,13 @@ export function lookAround(root, limit = 4000) {
53
54
  continue;
54
55
  }
55
56
  for (const entry of entries) {
56
- if (seen >= limit)
57
+ // Out of budget with entries still unread in *this* directory. Checking only the queue was
58
+ // not enough: a project whose files sit in one flat directory empties the queue before the
59
+ // budget runs out, so the look stopped short and did not know it.
60
+ if (seen >= limit) {
61
+ found.stoppedEarly = true;
57
62
  break;
63
+ }
58
64
  if (SKIP.has(entry) || (entry.startsWith('.') && entry !== '.github'))
59
65
  continue;
60
66
  const full = join(dir, entry);
@@ -78,7 +84,11 @@ export function lookAround(root, limit = 4000) {
78
84
  found.perfReports.push(rel);
79
85
  else if (/(k6|lighthouse|summary|lhr)[\w.-]*\.json$/i.test(entry) && info.size < 8_000_000)
80
86
  found.perfReports.push(rel);
81
- const inMigrations = /(^|\/)(migrations?|db\/migrate|changelog)\//i.test(rel);
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 - and
89
+ // the word may sit anywhere in it, because Keycloak's 83 are `jpa-changelog-<version>.xml`.
90
+ const inMigrations = /(^|\/)(migrations?|migration|db\/migrate|changelogs?)\//i.test(rel) ||
91
+ /(^|\/)[\w.-]*(liquibase|changelog)[\w.-]*\.(xml|ya?ml|json)$/i.test(rel);
82
92
  // Reading a file is only worth it for the two questions a name cannot answer: is this a
83
93
  // test, and is this a migration. Both are cheap, and both are often wrong from the path.
84
94
  const language = languageOf(entry);
@@ -101,6 +111,8 @@ export function lookAround(root, limit = 4000) {
101
111
  }
102
112
  }
103
113
  }
114
+ if (seen >= limit && queue.length > 0)
115
+ found.stoppedEarly = true;
104
116
  return found;
105
117
  }
106
118
  const total = (counts) => [...counts.values()].reduce((a, b) => a + b, 0);
@@ -139,19 +151,19 @@ export function suggest(found) {
139
151
  if (tests > 0) {
140
152
  out.push({
141
153
  command: 'tewip vouch',
142
- because: `${tests} test ${tests === 1 ? 'file' : 'files'} here (${named(found.tests)}). This reads them and names the ones that cannot fail - no model, a second or two.`,
154
+ because: `${found.stoppedEarly ? 'At least ' : ''}${tests} test ${tests === 1 ? 'file' : 'files'} here (${named(found.tests)}). This reads them and names the ones that cannot fail - no model, a second or two.`,
143
155
  });
144
156
  }
145
157
  if (migrations > 0) {
146
158
  out.push({
147
159
  command: 'tewip schema --diff main...HEAD',
148
- because: `${migrations} ${migrations === 1 ? 'migration' : 'migrations'} here (${named(found.migrations)}). This says what a change would do to a live database before it runs.`,
160
+ because: `${found.stoppedEarly ? 'At least ' : ''}${migrations} ${migrations === 1 ? 'migration' : 'migrations'} here (${named(found.migrations)}). This says what a change would do to a live database before it runs.`,
149
161
  });
150
162
  }
151
163
  if (found.markup > 0) {
152
164
  out.push({
153
165
  command: 'tewip access',
154
- because: `${found.markup} markup ${found.markup === 1 ? 'file' : 'files'} here. This names who cannot use the interface, against the WCAG criterion a regulator would cite.`,
166
+ because: `${found.stoppedEarly ? 'At least ' : ''}${found.markup} markup ${found.markup === 1 ? 'file' : 'files'} here. This names who cannot use the interface, against the WCAG criterion a regulator would cite.`,
155
167
  });
156
168
  }
157
169
  for (const report of found.perfReports.slice(0, 1)) {
@@ -187,6 +199,11 @@ export function renderStart(found, root) {
187
199
  }
188
200
  lines.push('## What it will not do here', '');
189
201
  const cannot = [];
202
+ if (found.stoppedEarly) {
203
+ cannot.push('This project is larger than Tewip looks at from here, so the counts above are **at least** ' +
204
+ 'that many rather than exactly. The commands themselves read everything; only this first ' +
205
+ 'glance is bounded, so that it stays instant.');
206
+ }
190
207
  if (total(found.tests) === 0)
191
208
  cannot.push('No test files were found, so nothing can be said about whether your tests could fail or would catch a bug.');
192
209
  if (total(found.migrations) === 0)
@@ -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)
@@ -216,9 +245,15 @@ const stripLines = (body, lines) => body
216
245
  .join(String.fromCharCode(10));
217
246
  /** The first line that does something, for the report to quote. */
218
247
  function firstRealLine(body) {
219
- const line = body
248
+ const lines = body
220
249
  .split(String.fromCharCode(10))
221
250
  .map((l) => l.trim())
222
- .find((l) => l !== '' && l !== '{' && l !== '}');
223
- return (line ?? '').slice(0, 120);
251
+ .filter((l) => l !== '' && l !== '{' && l !== '}');
252
+ // A comment is not the evidence. Saleor's tests are written `# given` / `# when / then`, so the
253
+ // quoted evidence for 166 real findings was the single character "#", which tells a reader
254
+ // nothing about the test it is accusing. The line that does something is the evidence.
255
+ // Nor is a line with nothing in it but punctuation: a lone quote closing a docstring, a stray
256
+ // bracket. Evidence has to be something a reader can recognise as the test doing work.
257
+ const code = lines.find((l) => !/^(#|\/\/|\/\*|\*|"""|'''|--)/.test(l) && /[A-Za-z0-9]/.test(l));
258
+ return (code ?? lines[0] ?? '').slice(0, 120);
224
259
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.49.0",
3
+ "version": "0.51.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": [