tewip-ai 0.76.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 +41 -0
- package/dist/engine/access/access-command.js +10 -1
- package/dist/engine/access/mobile.js +212 -0
- package/dist/engine/access/wcag.js +25 -0
- package/dist/engine/agent/agent-command.js +10 -52
- package/dist/engine/agent/scan.js +84 -0
- package/dist/engine/agent/tool-tests.js +87 -0
- package/package.json +1 -1
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
|
|
@@ -1252,6 +1285,14 @@ offline, in CI, before anything runs.
|
|
|
1252
1285
|
because that is the difference between a wrong answer and a tool call somebody else chose. On
|
|
1253
1286
|
1,691 files of real agent code: **zero findings**, which is the precision claim; recall is
|
|
1254
1287
|
measured against planted examples and said to be.
|
|
1288
|
+
- **Which of these tools anybody wrote a test about.** Not "does this project have tests" - every
|
|
1289
|
+
project has tests - but whether *this* tool is mentioned in any of them, and especially the ones
|
|
1290
|
+
that cannot be undone. A name too short or too common to look for is reported as undecidable
|
|
1291
|
+
rather than counted as covered. And the statement is made precisely in both directions: a tool
|
|
1292
|
+
missing from the list is not a tool whose behaviour is untested, it is a tool nothing tests **by
|
|
1293
|
+
that name** - the wiring between the name the model sees and the code behind it. Tewip finds 8 of
|
|
1294
|
+
its own 17 that way. Also names which eval framework the project uses, and which it looked for
|
|
1295
|
+
when it finds none.
|
|
1255
1296
|
- **What the coding agent installed here was permitted to do.** `.claude/settings.json` and
|
|
1256
1297
|
`.mcp.json` are committed, anybody with write access can change them, and neither turns up in a
|
|
1257
1298
|
review the way a source file does. Tewip names the approval gate switched off
|
|
@@ -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
|
|
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',
|
|
@@ -1,56 +1,13 @@
|
|
|
1
1
|
// `tewip agent`: what this agent is allowed to do, and what stands between it and the real world.
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
2
|
+
import { writeFileSync } from 'node:fs';
|
|
3
|
+
import { CAPABILITY_MEANS, FRAMEWORKS_READ, IRREVERSIBLE, trifectaIn } from './tools.js';
|
|
4
|
+
import {} from './prompt-flow.js';
|
|
5
|
+
import { configSection } from './claude-config.js';
|
|
6
|
+
import { coverageSection } from './tool-tests.js';
|
|
7
|
+
import { GUARD_MEANS, GUARD_PREVENTS } from './guards.js';
|
|
8
|
+
import { scanAgent } from './scan.js';
|
|
9
|
+
export { scanAgent };
|
|
9
10
|
const NL = String.fromCharCode(10);
|
|
10
|
-
const AGENT_FILE = /\.(?:py|[cm]?[jt]sx?)$/i;
|
|
11
|
-
/**
|
|
12
|
-
* What is not the shipped agent.
|
|
13
|
-
*
|
|
14
|
-
* The first version of this named the directory `tests` and the file `*.test.ts`, which is what the
|
|
15
|
-
* documentation of every project says and not what projects do. Run against the OpenAI Agents SDK,
|
|
16
|
-
* all 24 "tools" it reported were fixtures in `integration_tests/` and `test_*.py` - a directory
|
|
17
|
-
* whose name ends in `tests` rather than being `tests`, and a Python convention that puts the word
|
|
18
|
-
* at the front of the file. A list of an agent's tools that is actually a list of test doubles is
|
|
19
|
-
* worse than no list.
|
|
20
|
-
*
|
|
21
|
-
* `[\w-]+[_-]tests?` and not `[\w-]*tests?`, because the second one also excludes `latest/`.
|
|
22
|
-
*/
|
|
23
|
-
const NOT_SHIPPED = /(?:^|[\\/])(?:tests?|[\w-]+[_-]tests?|__tests__|spec|e2e|examples?|samples?|docs?|fixtures?|node_modules|\.venv|venv)[\\/]|\.(?:test|spec)\.[cm]?[jt]sx?$|_test\.py$|(?:^|[\\/])test_[^\\/]*\.py$/i;
|
|
24
|
-
export function scanAgent(root) {
|
|
25
|
-
const scan = scanSourceFiles(root, 20_000, (f) => AGENT_FILE.test(f));
|
|
26
|
-
const tools = [];
|
|
27
|
-
const injections = [];
|
|
28
|
-
const guards = [];
|
|
29
|
-
let emptyBudget = null;
|
|
30
|
-
let filesRead = 0;
|
|
31
|
-
for (const path of scan.files) {
|
|
32
|
-
const where = relative(root, path).replace(/\\/g, '/');
|
|
33
|
-
if (NOT_SHIPPED.test(where))
|
|
34
|
-
continue;
|
|
35
|
-
let text;
|
|
36
|
-
try {
|
|
37
|
-
text = readFileSync(path, 'utf8');
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
continue;
|
|
41
|
-
}
|
|
42
|
-
filesRead += 1;
|
|
43
|
-
tools.push(...toolsIn(where, text));
|
|
44
|
-
injections.push(...promptFlows(where, text));
|
|
45
|
-
guards.push(...guardsIn(where, text));
|
|
46
|
-
if (!emptyBudget) {
|
|
47
|
-
const empty = budgetSetToNothing(text);
|
|
48
|
-
if (empty)
|
|
49
|
-
emptyBudget = { file: where, text: empty };
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return { tools, injections, guards, filesRead, emptyBudget, config: scanConfig(root) };
|
|
53
|
-
}
|
|
54
11
|
const REACH_READS = {
|
|
55
12
|
destructive: 'cannot be undone',
|
|
56
13
|
deletes: 'deletes something',
|
|
@@ -62,7 +19,7 @@ const REACH_READS = {
|
|
|
62
19
|
'not-sure': '**not decided**',
|
|
63
20
|
};
|
|
64
21
|
export function renderAgent(scan, root) {
|
|
65
|
-
const { tools, injections, guards, filesRead, emptyBudget, config } = scan;
|
|
22
|
+
const { tools, injections, guards, filesRead, emptyBudget, config, coverage, evals } = scan;
|
|
66
23
|
const lines = ['# What this agent is allowed to do', ''];
|
|
67
24
|
if (tools.length === 0) {
|
|
68
25
|
return [
|
|
@@ -146,6 +103,7 @@ export function renderAgent(scan, root) {
|
|
|
146
103
|
lines.push(`- **${capability}** (${CAPABILITY_MEANS[capability]}): ${legTools.length === 0 ? 'nothing found' : legTools.slice(0, 6).map((t) => `\`${t.name}\``).join(', ') + (legTools.length > 6 ? `, and ${legTools.length - 6} more` : '')}`);
|
|
147
104
|
}
|
|
148
105
|
lines.push('');
|
|
106
|
+
lines.push(...coverageSection(coverage, tools, evals));
|
|
149
107
|
lines.push(...configSection(config));
|
|
150
108
|
lines.push('## What stands between them and the real world', '');
|
|
151
109
|
for (const kind of ['asks-a-person', 'dry-run', 'step-budget', 'tool-allow-list']) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Reading an agent project once: its tools, its flows, its guards, its tests and its permissions.
|
|
2
|
+
//
|
|
3
|
+
// Split from agent-command.ts, which is the file in this project that keeps arriving at the
|
|
4
|
+
// 300-line limit. The rendering belongs with the thing it renders; the walk belongs here.
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { join, relative } from 'node:path';
|
|
7
|
+
import { scanSourceFiles } from '../walk.js';
|
|
8
|
+
import { toolsIn } from './tools.js';
|
|
9
|
+
import { promptFlows } from './prompt-flow.js';
|
|
10
|
+
import { budgetSetToNothing, guardsIn } from './guards.js';
|
|
11
|
+
import { scanConfig } from './claude-config.js';
|
|
12
|
+
import { coverageOf, evalEvidence } from './tool-tests.js';
|
|
13
|
+
const NL = String.fromCharCode(10);
|
|
14
|
+
const AGENT_FILE = /\.(?:py|[cm]?[jt]sx?)$/i;
|
|
15
|
+
/**
|
|
16
|
+
* What is not the shipped agent.
|
|
17
|
+
*
|
|
18
|
+
* The first version of this named the directory `tests` and the file `*.test.ts`, which is what the
|
|
19
|
+
* documentation of every project says and not what projects do. Run against the OpenAI Agents SDK,
|
|
20
|
+
* all 24 "tools" it reported were fixtures in `integration_tests/` and `test_*.py` - a directory
|
|
21
|
+
* whose name ends in `tests` rather than being `tests`, and a Python convention that puts the word
|
|
22
|
+
* at the front of the file. A list of an agent's tools that is actually a list of test doubles is
|
|
23
|
+
* worse than no list.
|
|
24
|
+
*
|
|
25
|
+
* `[\w-]+[_-]tests?` and not `[\w-]*tests?`, because the second one also excludes `latest/`.
|
|
26
|
+
*/
|
|
27
|
+
const NOT_SHIPPED = /(?:^|[\\/])(?:tests?|[\w-]+[_-]tests?|__tests__|spec|e2e|examples?|samples?|docs?|fixtures?|node_modules|\.venv|venv)[\\/]|\.(?:test|spec)\.[cm]?[jt]sx?$|_test\.py$|(?:^|[\\/])test_[^\\/]*\.py$/i;
|
|
28
|
+
export function scanAgent(root) {
|
|
29
|
+
const scan = scanSourceFiles(root, 20_000, (f) => AGENT_FILE.test(f));
|
|
30
|
+
const tools = [];
|
|
31
|
+
const tests = [];
|
|
32
|
+
const injections = [];
|
|
33
|
+
const guards = [];
|
|
34
|
+
let emptyBudget = null;
|
|
35
|
+
let filesRead = 0;
|
|
36
|
+
for (const path of scan.files) {
|
|
37
|
+
const where = relative(root, path).replace(/\\/g, '/');
|
|
38
|
+
// Excluded from the tool scan, but not thrown away: these files are the evidence for whether
|
|
39
|
+
// anybody ever wrote a test about a tool, which is a different question from what ships.
|
|
40
|
+
if (NOT_SHIPPED.test(where)) {
|
|
41
|
+
try {
|
|
42
|
+
tests.push({ file: where, text: readFileSync(path, 'utf8') });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// A file that cannot be read is not evidence of a test, and is not counted as one.
|
|
46
|
+
}
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
let text;
|
|
50
|
+
try {
|
|
51
|
+
text = readFileSync(path, 'utf8');
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
filesRead += 1;
|
|
57
|
+
tools.push(...toolsIn(where, text));
|
|
58
|
+
injections.push(...promptFlows(where, text));
|
|
59
|
+
guards.push(...guardsIn(where, text));
|
|
60
|
+
if (!emptyBudget) {
|
|
61
|
+
const empty = budgetSetToNothing(text);
|
|
62
|
+
if (empty)
|
|
63
|
+
emptyBudget = { file: where, text: empty };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const manifests = ['package.json', 'pyproject.toml', 'requirements.txt'].flatMap((m) => {
|
|
67
|
+
try {
|
|
68
|
+
return [readFileSync(join(root, m), 'utf8')];
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
tools,
|
|
76
|
+
injections,
|
|
77
|
+
guards,
|
|
78
|
+
filesRead,
|
|
79
|
+
emptyBudget,
|
|
80
|
+
coverage: coverageOf(tools.map((t) => t.name), tests),
|
|
81
|
+
evals: evalEvidence(tests, manifests),
|
|
82
|
+
config: scanConfig(root),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eval frameworks, by the name they are imported or depended on under.
|
|
3
|
+
*
|
|
4
|
+
* Named rather than guessed at. The point of listing them is to be able to say "this project has
|
|
5
|
+
* one, here it is" or "none of these is here" - and the second sentence is only honest if the list
|
|
6
|
+
* is written down where a reader can see what was looked for.
|
|
7
|
+
*/
|
|
8
|
+
const FRAMEWORKS = [
|
|
9
|
+
{ name: 'DeepEval', re: /\bdeepeval\b/i },
|
|
10
|
+
{ name: 'promptfoo', re: /\bpromptfoo\b/i },
|
|
11
|
+
{ name: 'Ragas', re: /\bragas\b/i },
|
|
12
|
+
{ name: 'LangSmith', re: /\blangsmith\b/i },
|
|
13
|
+
{ name: 'Braintrust', re: /\bbraintrust\b/i },
|
|
14
|
+
{ name: 'Inspect AI', re: /\binspect_ai\b/i },
|
|
15
|
+
{ name: 'Giskard', re: /\bgiskard\b/i },
|
|
16
|
+
{ name: 'TruLens', re: /\btrulens\b/i },
|
|
17
|
+
{ name: 'Langfuse', re: /\blangfuse\b/i },
|
|
18
|
+
{ name: 'OpenAI Evals', re: /\bopenai[-_]?evals\b/i },
|
|
19
|
+
];
|
|
20
|
+
/** A path that holds an eval set rather than a unit test. */
|
|
21
|
+
const EVAL_PATH = /(?:^|[\\/])(?:evals?|benchmarks?|golden|datasets?)[\\/]|\.eval\.[cm]?[jt]s$|_eval\.py$|eval_[^\\/]*\.(?:py|[cm]?[jt]s)$|\bevals?\.ya?ml$/i;
|
|
22
|
+
/**
|
|
23
|
+
* Names too common to decide on.
|
|
24
|
+
*
|
|
25
|
+
* A tool called `get` or `run` appears in every file in the project, so finding it in a test file
|
|
26
|
+
* says nothing at all. Rather than counting those as covered - which is the comfortable direction
|
|
27
|
+
* and the wrong one - they are reported as undecidable, by name.
|
|
28
|
+
*/
|
|
29
|
+
const COMMON = new Set(['get', 'set', 'run', 'add', 'list', 'read', 'write', 'call', 'send', 'find', 'query', 'search', 'name', 'tool', 'test', 'echo', 'exec', 'open', 'close', 'load', 'save']);
|
|
30
|
+
const tooCommonName = (name) => name.length < 5 || COMMON.has(name.toLowerCase());
|
|
31
|
+
export function coverageOf(toolNames, tests) {
|
|
32
|
+
return toolNames.map((name) => {
|
|
33
|
+
if (tooCommonName(name))
|
|
34
|
+
return { name, mentionedIn: [], tooCommon: true };
|
|
35
|
+
// Word boundaries on both sides: `delete_user` must not be matched by `delete_user_profile`,
|
|
36
|
+
// which is a different tool and may well be the only one anybody tested.
|
|
37
|
+
const mention = new RegExp(String.raw `(?<![\w-])${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\w-])`);
|
|
38
|
+
return { name, mentionedIn: tests.filter((t) => mention.test(t.text)).map((t) => t.file), tooCommon: false };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function evalEvidence(files, manifests) {
|
|
42
|
+
const frameworks = new Set();
|
|
43
|
+
for (const { name, re } of FRAMEWORKS) {
|
|
44
|
+
if (manifests.some((m) => re.test(m)) || files.some((f) => re.test(f.text)))
|
|
45
|
+
frameworks.add(name);
|
|
46
|
+
}
|
|
47
|
+
const evalFiles = files.filter((f) => EVAL_PATH.test(f.file)).map((f) => f.file);
|
|
48
|
+
return { frameworks: [...frameworks], files: [...new Set(evalFiles)].slice(0, 12) };
|
|
49
|
+
}
|
|
50
|
+
/** The names of every eval framework looked for, so silence about them can be read correctly. */
|
|
51
|
+
export const FRAMEWORKS_LOOKED_FOR = FRAMEWORKS.map((f) => f.name);
|
|
52
|
+
/**
|
|
53
|
+
* Which tools anybody wrote a test about, rendered where the logic for deciding it lives.
|
|
54
|
+
*
|
|
55
|
+
* Split out for the same reason the config section was: agent-command.ts is the file that keeps
|
|
56
|
+
* reaching the 300-line limit, and a report section belongs beside the thing it reports on.
|
|
57
|
+
*/
|
|
58
|
+
export function coverageSection(coverage, tools, evals) {
|
|
59
|
+
const lines = [];
|
|
60
|
+
const IRREV = ['destructive', 'deletes', 'pays', 'sends', 'runs-code'];
|
|
61
|
+
const untested = coverage.filter((c) => !c.tooCommon && c.mentionedIn.length === 0);
|
|
62
|
+
const riskyUntested = untested.filter((u) => IRREV.includes(tools.find((t) => t.name === u.name)?.reach ?? 'reads'));
|
|
63
|
+
lines.push('## Which of these anybody wrote a test about', '');
|
|
64
|
+
lines.push(untested.length === 0
|
|
65
|
+
? 'Every tool whose name is distinctive enough to look for appears in at least one test file.'
|
|
66
|
+
: `**${untested.length} of ${tools.length}** appear in no test file${riskyUntested.length > 0 ? `, and ${riskyUntested.length} of those cannot be undone` : ''}.`, '');
|
|
67
|
+
if (untested.length > 0) {
|
|
68
|
+
for (const u of untested.slice(0, 15)) {
|
|
69
|
+
const reach = tools.find((t) => t.name === u.name)?.reach;
|
|
70
|
+
lines.push(`- \`${u.name}\`${reach && IRREV.includes(reach) ? ' — **and it cannot be undone**' : ''}`);
|
|
71
|
+
}
|
|
72
|
+
if (untested.length > 15)
|
|
73
|
+
lines.push(`…and ${untested.length - 15} more`);
|
|
74
|
+
lines.push('');
|
|
75
|
+
}
|
|
76
|
+
const undecidable = coverage.filter((c) => c.tooCommon);
|
|
77
|
+
if (undecidable.length > 0) {
|
|
78
|
+
lines.push(`Not decided for ${undecidable.length}: ${undecidable.slice(0, 8).map((u) => `\`${u.name}\``).join(', ')}. A name this`, 'short or this common appears everywhere, so finding it in a test file would prove nothing.', '');
|
|
79
|
+
}
|
|
80
|
+
lines.push(evals.frameworks.length > 0
|
|
81
|
+
? `Eval framework in this project: ${evals.frameworks.join(', ')}.`
|
|
82
|
+
: `No eval framework found. Looked for: ${FRAMEWORKS_LOOKED_FOR.join(', ')}.`, '');
|
|
83
|
+
if (evals.files.length > 0)
|
|
84
|
+
lines.push(`Eval sets: ${evals.files.map((f) => `\`${f}\``).join(', ')}.`, '');
|
|
85
|
+
lines.push('A name appearing in a test file means somebody wrote it down there. Whether that test exercises', 'the tool, mocks it, or only imports it is not decided here.', '', 'And the other way round, which matters more: a tool missing from this list is not a tool whose', 'behaviour is untested. It is a tool nothing tests **by that name** - the wiring between the name', 'the model sees and the code behind it. Tewip finds 8 of its own 17 this way, and the engine under', 'each one is covered; what nothing exercises is the tool surface itself.', '');
|
|
86
|
+
return lines;
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tewip-ai",
|
|
3
|
-
"version": "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": [
|