tewip-ai 0.77.0 → 0.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1216,6 +1216,39 @@ With `--act` it carries the decision out, and this is where the refusals matter
1216
1216
  - Everything it did or refused goes to `.tewip/actions.jsonl`, with the run that justified it.
1217
1217
  - It **will not repeat itself**: the same attempt is refused until a new run gives it new evidence.
1218
1218
 
1219
+ ## Can everybody use the phone app? (Phase 143)
1220
+
1221
+ ```bash
1222
+ tewip access # web markup and mobile screens, in one command
1223
+ ```
1224
+
1225
+ Every accessibility tool worth having points at a rendered page. A phone app has no page to point
1226
+ at without a simulator, a device farm or a build, so in practice nobody checks the mobile half -
1227
+ and the mobile half is where the two barriers that matter most are invisible by design. A fixed
1228
+ text size and a missing label both look perfect on the reviewer's screen. They appear when somebody
1229
+ turns their font size up, or turns VoiceOver on, which is after shipping.
1230
+
1231
+ `tewip access` now reads **SwiftUI** and **Jetpack Compose** source alongside web markup:
1232
+
1233
+ - **An image with no name.** SwiftUI: no `.accessibilityLabel` and nothing marking it decoration.
1234
+ Compose: `contentDescription = null` inside something tappable, or a description that names
1235
+ nothing - `"image"`, `"icon"`, `""`. An SF Symbol is left to a person, because VoiceOver reads
1236
+ the symbol name and sometimes that is good enough.
1237
+ - **Text that will not grow.** `fontSize = 14.dp` in Compose ignores the font scale entirely; `sp`
1238
+ honours it. `.font(.system(size: 13))` in SwiftUI opts out of Dynamic Type unless it carries
1239
+ `relativeTo:`.
1240
+ - **A tap target smaller than a finger**, and only where something is actually tappable.
1241
+ - **Something made clickable with no role**, so TalkBack never says it is a control.
1242
+
1243
+ **Measured on two real apps.** Google's Compose samples: **0 barriers**, 5 for a person to settle.
1244
+ isowords, a shipped SwiftUI game: 20 barriers, 62 to settle. Both found this check wrong first:
1245
+
1246
+ - isowords has `Image(systemName: "xmark.circle.fill").font(.system(size: 24))`, where `.font` sets
1247
+ the size of a glyph. Ten findings claimed text would not grow about something that is not text.
1248
+ - Google's JetNews has a `Row` with `.semantics { customActions = … }` - the correct handling, and
1249
+ the reason the sample exists. A pattern wanting `semantics(` walked past the trailing-lambda form
1250
+ everybody writes, and accused the reference implementation of the barrier it demonstrates fixing.
1251
+
1219
1252
  ## What is your AI agent allowed to do? (Phase 139)
1220
1253
 
1221
1254
  ```bash
@@ -14,6 +14,7 @@ import { dirname, join, relative, resolve } from 'node:path';
14
14
  import { scanSourceFiles } from '../walk.js';
15
15
  import { readHandWritten } from '../../reporters/store.js';
16
16
  import { JS_LIKE, MARKUP, tags } from './markup.js';
17
+ import { MOBILE, mobileBarriers } from './mobile.js';
17
18
  import { checkFile } from './checks.js';
18
19
  import { readAxeReport } from './reports.js';
19
20
  import { isLighthouseFile, readLighthouse } from '../perf/lighthouse.js';
@@ -48,7 +49,8 @@ export function findBarriers(root, only) {
48
49
  // scanner's 5,000-file cap over *all* text, which on n8n let 9 of 1,456 markup files through -
49
50
  // and none survived the test-file filter, so `access` reported no barriers having opened
50
51
  // nothing. A clean accessibility report is the one that must never be an artifact of a budget.
51
- const scan = existsSync(base) && MARKUP.test(base) ? { files: [base], total: 1 } : scanSourceFiles(base, 20_000, (f) => MARKUP.test(f));
52
+ const reads = (f) => MARKUP.test(f) || MOBILE.test(f);
53
+ const scan = existsSync(base) && reads(base) ? { files: [base], total: 1 } : scanSourceFiles(base, 20_000, reads);
52
54
  const all = scan.files;
53
55
  // Asking for a path by name overrides the exclusion: if you point at a fixture, you want it read.
54
56
  const files = only ? all : all.filter((f) => !TEST_MARKUP.test(relative(root, f)));
@@ -66,6 +68,13 @@ export function findBarriers(root, only) {
66
68
  if (source.length > 400_000)
67
69
  continue;
68
70
  filesRead++;
71
+ const where = relative(root, path).replaceAll('\\', '/');
72
+ // A phone screen is markup too, and the same question - can everybody use this - but it is not
73
+ // written in tags, so it goes through its own reader rather than the tag extractor.
74
+ if (MOBILE.test(path)) {
75
+ findings.push(...mobileBarriers(where, source));
76
+ continue;
77
+ }
69
78
  const jsLike = JS_LIKE.test(path);
70
79
  // One page, rather than a file of components: only then do repeated ids land in one document.
71
80
  const isDocument = /\.html?$/i.test(path);
@@ -0,0 +1,212 @@
1
+ const NL = String.fromCharCode(10);
2
+ export const MOBILE = /\.(?:swift|kt)$/i;
3
+ /** Apple's minimum is 44pt, Google's is 48dp. A control smaller than that is hard to hit. */
4
+ const MIN_TAP = { swift: 44, kotlin: 48 };
5
+ /** Placeholder text that is a label in form only. */
6
+ const PLACEHOLDER = /^(?:|\s*|icon|image|picture|photo|img|button|todo|tbd|placeholder|null|none|n\/a|\.\.\.)$/i;
7
+ const lineOf = (text, index) => text.slice(0, index).split(NL).length;
8
+ const shown = (s) => s.replace(/\s+/g, ' ').trim().slice(0, 90);
9
+ /**
10
+ * The call's own arguments, up to the matching bracket.
11
+ *
12
+ * Needed because a modifier that supplies the label sits *after* the call it labels -
13
+ * `Image("x").accessibilityLabel("A cat")` - so a check on the call alone would accuse every
14
+ * labelled image in the file.
15
+ */
16
+ function callAndModifiers(text, from) {
17
+ let depth = 0;
18
+ let i = text.indexOf('(', from);
19
+ if (i === -1)
20
+ return text.slice(from, from + 120);
21
+ for (; i < text.length && i < from + 2000; i += 1) {
22
+ const ch = text[i];
23
+ if (ch === '(' || ch === '[' || ch === '{')
24
+ depth += 1;
25
+ else if (ch === ')' || ch === ']' || ch === '}') {
26
+ depth -= 1;
27
+ if (depth === 0)
28
+ break;
29
+ }
30
+ }
31
+ // Then the chained modifiers that follow, which is where SwiftUI puts accessibility.
32
+ let end = i + 1;
33
+ while (end < text.length && end < from + 4000) {
34
+ const rest = text.slice(end);
35
+ const chained = /^\s*\.\s*\w+\s*\(/.exec(rest);
36
+ if (!chained)
37
+ break;
38
+ let d = 0;
39
+ let j = end + rest.indexOf('(');
40
+ for (; j < text.length; j += 1) {
41
+ if (text[j] === '(')
42
+ d += 1;
43
+ else if (text[j] === ')') {
44
+ d -= 1;
45
+ if (d === 0)
46
+ break;
47
+ }
48
+ }
49
+ end = j + 1;
50
+ }
51
+ return text.slice(from, end);
52
+ }
53
+ /**
54
+ * What a chained modifier is attached to.
55
+ *
56
+ * SwiftUI puts each modifier on its own line under the thing it modifies, so the owner is the
57
+ * first line above that does not itself begin with a dot. Needed because `.font(.system(size:))`
58
+ * means two different things depending on what it hangs off: a text size on a `Text`, and a glyph
59
+ * size on an `Image`.
60
+ */
61
+ function chainOwner(text, at) {
62
+ const before = text.slice(0, at).split(NL);
63
+ for (let i = before.length - 1; i >= 0 && i > before.length - 8; i -= 1) {
64
+ const line = before[i].trim();
65
+ if (line === '' || line.startsWith('.'))
66
+ continue;
67
+ return line;
68
+ }
69
+ return '';
70
+ }
71
+ function swiftUI(file, text) {
72
+ const out = [];
73
+ // An image with no label and nothing saying it is decoration.
74
+ for (const m of text.matchAll(/\bImage\s*\(/g)) {
75
+ const at = m.index ?? 0;
76
+ const whole = callAndModifiers(text, at);
77
+ // `Image(decorative:)` is the documented way to say this conveys nothing. So is hiding it.
78
+ if (/\bdecorative\s*:/.test(whole) || /accessibilityHidden\s*\(\s*true\s*\)/.test(whole))
79
+ continue;
80
+ if (/accessibilityLabel\s*\(/.test(whole) || /accessibilityElement\s*\(/.test(whole))
81
+ continue;
82
+ // An SF Symbol carries a name VoiceOver can read; an asset name is a file name.
83
+ const symbol = /\bImage\s*\(\s*systemName\s*:/.test(whole);
84
+ out.push({
85
+ rule: symbol ? 'mobile-image-label' : 'mobile-image-label',
86
+ confidence: symbol ? 'ask-a-person' : 'barrier',
87
+ file,
88
+ line: lineOf(text, at),
89
+ element: shown(whole),
90
+ why: symbol
91
+ ? 'an SF Symbol with no accessibilityLabel: VoiceOver falls back to the symbol name, which is a developer name, not a description'
92
+ : 'no accessibilityLabel, and nothing marks it as decoration',
93
+ next: symbol
94
+ ? 'add .accessibilityLabel("…") saying what it conveys here, or .accessibilityHidden(true) if the text beside it already says it'
95
+ : 'add .accessibilityLabel("…"), or use Image(decorative:) if it conveys nothing',
96
+ });
97
+ }
98
+ // A text size that ignores the reader's font setting.
99
+ for (const m of text.matchAll(/\.\s*font\s*\(\s*\.\s*system\s*\(\s*size\s*:\s*(\d+)/g)) {
100
+ const at = m.index ?? 0;
101
+ const whole = callAndModifiers(text, at);
102
+ // `relativeTo:` opts the fixed size back into Dynamic Type, which is the documented fix.
103
+ if (/relativeTo\s*:/.test(whole))
104
+ continue;
105
+ // On an Image, `.font(.system(size:))` sets the size of an SF Symbol, not of any text. isowords
106
+ // has `Image(systemName: "xmark.circle.fill").font(.system(size: 24))`, and calling that text
107
+ // that will not grow is a confident wrong answer about a glyph.
108
+ if (/\bImage\s*\(/.test(chainOwner(text, at)))
109
+ continue;
110
+ out.push({
111
+ rule: 'mobile-text-not-scalable',
112
+ confidence: 'barrier',
113
+ file,
114
+ line: lineOf(text, at),
115
+ element: shown(m[0]),
116
+ why: `a fixed ${m[1]}pt size, so this text does not grow when the reader turns their font size up`,
117
+ next: 'use a semantic font like .font(.body), or .font(.system(size: 14, relativeTo: .body))',
118
+ });
119
+ }
120
+ // A tap target declared smaller than a finger.
121
+ for (const m of text.matchAll(/\.\s*frame\s*\(\s*width\s*:\s*(\d+)\s*,\s*height\s*:\s*(\d+)/g)) {
122
+ const w = Number(m[1]);
123
+ const h = Number(m[2]);
124
+ if (w >= MIN_TAP.swift && h >= MIN_TAP.swift)
125
+ continue;
126
+ const at = m.index ?? 0;
127
+ // Only when it is actually tappable: a small decorative frame is nobody's problem.
128
+ const around = text.slice(Math.max(0, at - 400), at + 400);
129
+ if (!/\bButton\s*\(|onTapGesture|\.\s*tapGesture|NavigationLink|\bToggle\s*\(/.test(around))
130
+ continue;
131
+ out.push({
132
+ rule: 'mobile-tap-target',
133
+ confidence: 'ask-a-person',
134
+ file,
135
+ line: lineOf(text, at),
136
+ element: shown(m[0]),
137
+ why: `a tappable area declared ${w}x${h}pt, under the 44pt Apple asks for - unless padding around it makes the hit area bigger, which this cannot see`,
138
+ next: 'give it .frame(minWidth: 44, minHeight: 44), or enough padding to reach 44pt',
139
+ });
140
+ }
141
+ return out;
142
+ }
143
+ function compose(file, text) {
144
+ const out = [];
145
+ // Compose makes contentDescription compulsory, so the failure is what gets passed to it.
146
+ for (const m of text.matchAll(/\b(?:Image|Icon)\s*\([^)]{0,400}?contentDescription\s*=\s*(null|"([^"]*)")/g)) {
147
+ const at = m.index ?? 0;
148
+ const whole = m[0];
149
+ const isNull = m[1] === 'null';
150
+ const value = m[2] ?? '';
151
+ // `null` is the documented way to say an image is decorative, and inside a labelled button it
152
+ // is right. On its own it is the commonest way an icon ends up with no name at all.
153
+ const around = text.slice(Math.max(0, at - 300), at + 300);
154
+ const insideClickable = /\b(?:IconButton|Button|Clickable|clickable)\b/.test(around);
155
+ if (isNull && !insideClickable)
156
+ continue;
157
+ if (!isNull && !PLACEHOLDER.test(value))
158
+ continue;
159
+ out.push({
160
+ rule: 'mobile-image-label',
161
+ confidence: isNull ? 'barrier' : 'barrier',
162
+ file,
163
+ line: lineOf(text, at),
164
+ element: shown(whole),
165
+ why: isNull
166
+ ? 'contentDescription = null inside something tappable: TalkBack announces a button with no name'
167
+ : `contentDescription is ${value === '' ? 'empty' : `"${value}"`}, which names nothing`,
168
+ next: 'give contentDescription the words a person needs, or keep null only when a text label beside it already says it',
169
+ });
170
+ }
171
+ // dp for a text size is always wrong: dp ignores the font scale, sp honours it.
172
+ for (const m of text.matchAll(/\bfontSize\s*=\s*(\d+)\s*\.\s*dp\b/g)) {
173
+ const at = m.index ?? 0;
174
+ out.push({
175
+ rule: 'mobile-text-not-scalable',
176
+ confidence: 'barrier',
177
+ file,
178
+ line: lineOf(text, at),
179
+ element: shown(m[0]),
180
+ why: `a text size in dp, which ignores the reader's font-size setting entirely. sp is the unit that honours it`,
181
+ next: `use ${m[1]}.sp instead of ${m[1]}.dp`,
182
+ });
183
+ }
184
+ // Something made tappable without being told it is a control.
185
+ // `clickable\s*[({]` and not `clickable\s*\(`: Kotlin's trailing lambda means the commonest form
186
+ // by far is `Modifier.clickable { open() }`, with a brace. Written with the paren alone this
187
+ // matched the form almost nobody uses and stayed silent on the one everybody does.
188
+ for (const m of text.matchAll(/\b(Box|Row|Column|Text|Surface|Spacer)\s*\(\s*[^)]{0,300}?Modifier\s*[^)]{0,300}?\.\s*clickable\s*[({]/g)) {
189
+ const at = m.index ?? 0;
190
+ const whole = callAndModifiers(text, at);
191
+ // `semantics\s*[({]` for the same reason as `clickable` above, and this one was caught by
192
+ // accusing Google's own JetNews sample of the exact thing it exists to demonstrate: its Row has
193
+ // `.semantics { customActions = listOf(CustomAccessibilityAction(...)) }`, with a brace, and a
194
+ // pattern wanting a paren walked straight past it. A check that reports the reference
195
+ // implementation as a barrier is not a strict check, it is a broken one.
196
+ if (/\bsemantics\s*[({]|\brole\s*=\s*Role\.|\bRole\.\w+|\bonClickLabel\s*=/.test(whole))
197
+ continue;
198
+ out.push({
199
+ rule: 'mobile-control-unnamed',
200
+ confidence: 'ask-a-person',
201
+ file,
202
+ line: lineOf(text, at),
203
+ element: shown(m[0]),
204
+ why: `a ${m[1]} made clickable with no role and no semantics, so TalkBack does not announce it as a control`,
205
+ next: 'pass Modifier.clickable(onClickLabel = "…", role = Role.Button), or use a Button',
206
+ });
207
+ }
208
+ return out;
209
+ }
210
+ export function mobileBarriers(file, text) {
211
+ return /\.swift$/i.test(file) ? swiftUI(file, text) : compose(file, text);
212
+ }
@@ -7,6 +7,31 @@
7
7
  // trace to a numbered criterion is a finding they can put in front of a lawyer or a regulator; a
8
8
  // finding that only says "bad contrast" is not. So every rule carries its criterion and its level.
9
9
  export const RULES = {
10
+ // Phase 143: the mobile screens, which nobody checks because there is no page to point at.
11
+ 'mobile-image-label': {
12
+ criterion: '1.1.1 Non-text Content',
13
+ level: 'A',
14
+ severity: 'blocker',
15
+ stops: 'VoiceOver or TalkBack announces "button" and stops, or reads out an asset file name',
16
+ },
17
+ 'mobile-text-not-scalable': {
18
+ criterion: '1.4.4 Resize Text',
19
+ level: 'AA',
20
+ severity: 'blocker',
21
+ stops: 'somebody who turns their font size up gets the same small text, because the app opted out',
22
+ },
23
+ 'mobile-tap-target': {
24
+ criterion: '2.5.8 Target Size (Minimum)',
25
+ level: 'AA',
26
+ severity: 'serious',
27
+ stops: 'anybody whose hands are unsteady, on a moving bus, or using one thumb',
28
+ },
29
+ 'mobile-control-unnamed': {
30
+ criterion: '4.1.2 Name, Role, Value',
31
+ level: 'A',
32
+ severity: 'serious',
33
+ stops: 'a screen reader never says this is something you can press',
34
+ },
10
35
  'image-alt': {
11
36
  criterion: '1.1.1 Non-text Content',
12
37
  level: 'A',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tewip-ai",
3
- "version": "0.77.0",
3
+ "version": "0.78.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": [