staysfixed 0.3.1 → 0.4.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 +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Five hundred differences are not five hundred findings.
|
|
3
|
+
*
|
|
4
|
+
* Rename one field on a response and it differs on every response. Change one
|
|
5
|
+
* colour token and it differs on every screen. Reported one line at a time that
|
|
6
|
+
* is a wall an agent will not read and a person cannot judge, and the tool
|
|
7
|
+
* becomes the thing everybody switches off. So differences are grouped by what
|
|
8
|
+
* they ARE rather than by where they happened: same channel, same shape of
|
|
9
|
+
* change, same move from one value to another. One finding, with a count.
|
|
10
|
+
*
|
|
11
|
+
* The grouping key is deliberately exact in one place and coarse in another.
|
|
12
|
+
* Exact on the value transition, because "5 became 6" and "5 became 9" are two
|
|
13
|
+
* different bugs. Coarse on the address, because the whole point is that the
|
|
14
|
+
* same thing happened in two hundred places and their addresses all differ.
|
|
15
|
+
*
|
|
16
|
+
* One special case earns its own code: a rename arrives as two unrelated
|
|
17
|
+
* differences — something vanished, something appeared — and grouping them
|
|
18
|
+
* separately reports one edit as two findings, which is exactly the confusion
|
|
19
|
+
* this file exists to prevent.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { sha256, shortHash } from '../core/hash.js';
|
|
23
|
+
import { splitPath, sameValue } from './observation.js';
|
|
24
|
+
|
|
25
|
+
/** @typedef {import('./types.js').Difference} Difference */
|
|
26
|
+
/** @typedef {import('./types.js').Finding} Finding */
|
|
27
|
+
/** @typedef {import('./types.js').Channel} Channel */
|
|
28
|
+
/** @typedef {import('./types.js').ObservedValue} ObservedValue */
|
|
29
|
+
|
|
30
|
+
/** How much of a cluster a finding carries with it. Enough to orient, not enough to bury. */
|
|
31
|
+
const KEEP_NEAR_FILES = 5;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* How many of a cluster's addresses travel with the finding. `count` always says how
|
|
35
|
+
* many there really are, so a long cluster cannot hide its size behind a short list.
|
|
36
|
+
*/
|
|
37
|
+
const KEEP_PATHS = 20;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How each channel introduces itself. This is the first half of every sentence
|
|
41
|
+
* an agent reads, so it is written for somebody who has never seen this tool.
|
|
42
|
+
*
|
|
43
|
+
* @type {Record<Channel, string>}
|
|
44
|
+
*/
|
|
45
|
+
const CHANNEL_WORDS = {
|
|
46
|
+
meaning: 'On screen',
|
|
47
|
+
effects: 'In what the program sends out',
|
|
48
|
+
complaints: 'In what the program complains about',
|
|
49
|
+
results: 'In what the program gives back',
|
|
50
|
+
contract: 'In the doors the code opens',
|
|
51
|
+
counters: 'In the counts and timings',
|
|
52
|
+
pixels: 'In the picture',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Last segments too vague to identify anything alone. When an address ends in
|
|
57
|
+
* one of these, the segment before it comes along for the ride.
|
|
58
|
+
*/
|
|
59
|
+
const VAGUE = new Set([
|
|
60
|
+
'value',
|
|
61
|
+
'text',
|
|
62
|
+
'name',
|
|
63
|
+
'state',
|
|
64
|
+
'label',
|
|
65
|
+
'title',
|
|
66
|
+
'count',
|
|
67
|
+
'enabled',
|
|
68
|
+
'visible',
|
|
69
|
+
'status',
|
|
70
|
+
'type',
|
|
71
|
+
'id',
|
|
72
|
+
'body',
|
|
73
|
+
'result',
|
|
74
|
+
'exit',
|
|
75
|
+
]);
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Group differences into findings.
|
|
79
|
+
*
|
|
80
|
+
* @param {Difference[]} differences
|
|
81
|
+
* @param {{sources?: Record<string, string>}} [opts]
|
|
82
|
+
* `sources` maps an observation path to the source file it came from, which is
|
|
83
|
+
* what lets ranking work out how far a finding is from the edit. Build it from
|
|
84
|
+
* the candidate captures' observation meta; leave it out and findings simply
|
|
85
|
+
* say their distance is unknown.
|
|
86
|
+
* @returns {Finding[]}
|
|
87
|
+
*/
|
|
88
|
+
export function clusterDifferences(differences, opts = {}) {
|
|
89
|
+
const list = differences ?? [];
|
|
90
|
+
const renames = findRenames(list);
|
|
91
|
+
|
|
92
|
+
/** @type {Map<string, Difference[]>} */
|
|
93
|
+
const groups = new Map();
|
|
94
|
+
/** @type {Map<string, {from: string, to: string}>} */
|
|
95
|
+
const renameNames = new Map();
|
|
96
|
+
|
|
97
|
+
for (const d of list) {
|
|
98
|
+
const rename = renames.get(d);
|
|
99
|
+
const signature = rename
|
|
100
|
+
? `${d.channel} | renamed | ${generalise(rename.from)} | ${generalise(rename.to)}`
|
|
101
|
+
: signatureOf(d);
|
|
102
|
+
if (rename) renameNames.set(signature, rename);
|
|
103
|
+
const bucket = groups.get(signature);
|
|
104
|
+
if (bucket) bucket.push(d);
|
|
105
|
+
else groups.set(signature, [d]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @type {Finding[]} */
|
|
109
|
+
const findings = [];
|
|
110
|
+
for (const [signature, members] of groups) {
|
|
111
|
+
findings.push(buildFinding(signature, members, renameNames.get(signature), opts.sources ?? {}));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Biggest first is only a starting order — rank.js decides the real one — but
|
|
115
|
+
// a stable order matters, because two runs that found the same things have to
|
|
116
|
+
// hand back the same list in the same order.
|
|
117
|
+
findings.sort((a, b) => (b.count ?? 0) - (a.count ?? 0) || a.id.localeCompare(b.id));
|
|
118
|
+
return findings;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Spot the pairs where one name was swapped for another.
|
|
123
|
+
*
|
|
124
|
+
* A rename is: in the same place, on the same channel, one address went away,
|
|
125
|
+
* one arrived, and they hold the same value. Two conditions carry the weight.
|
|
126
|
+
* Exactly one of each in that place — two of each is a rewrite, not a rename,
|
|
127
|
+
* and guessing which pairs with which would be a fiction. And the values must
|
|
128
|
+
* match, or this is two unrelated edits that happened to land side by side.
|
|
129
|
+
*
|
|
130
|
+
* @param {Difference[]} differences
|
|
131
|
+
* @returns {Map<Difference, {from: string, to: string}>} the differences that are
|
|
132
|
+
* halves of a rename, each pointing at the old and new name
|
|
133
|
+
*/
|
|
134
|
+
export function findRenames(differences) {
|
|
135
|
+
/** @type {Map<string, {gone: Difference[], came: Difference[]}>} */
|
|
136
|
+
const places = new Map();
|
|
137
|
+
for (const d of differences) {
|
|
138
|
+
if (d.kind !== 'vanished' && d.kind !== 'appeared') continue;
|
|
139
|
+
const at = `${d.journey ?? ''} ${d.channel} ${parentOf(d.path)}`;
|
|
140
|
+
const place =
|
|
141
|
+
places.get(at) ?? /** @type {{gone: Difference[], came: Difference[]}} */ ({ gone: [], came: [] });
|
|
142
|
+
if (d.kind === 'vanished') place.gone.push(d);
|
|
143
|
+
else place.came.push(d);
|
|
144
|
+
places.set(at, place);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** @type {Map<Difference, {from: string, to: string}>} */
|
|
148
|
+
const found = new Map();
|
|
149
|
+
for (const place of places.values()) {
|
|
150
|
+
if (place.gone.length !== 1 || place.came.length !== 1) continue;
|
|
151
|
+
const gone = place.gone[0];
|
|
152
|
+
const came = place.came[0];
|
|
153
|
+
if (!sameValue(gone.reference, came.candidate)) continue;
|
|
154
|
+
const from = leafOf(gone.path);
|
|
155
|
+
const to = leafOf(came.path);
|
|
156
|
+
if (from === to) continue;
|
|
157
|
+
found.set(gone, { from, to });
|
|
158
|
+
found.set(came, { from, to });
|
|
159
|
+
}
|
|
160
|
+
return found;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The grouping key: channel, shape of change, what the address ends in, and the
|
|
165
|
+
* move from one value to another.
|
|
166
|
+
*
|
|
167
|
+
* @param {Difference} d
|
|
168
|
+
* @returns {string}
|
|
169
|
+
*/
|
|
170
|
+
export function signatureOf(d) {
|
|
171
|
+
return [d.channel, d.kind, generalise(smartLeaf(d.path)), faceOf(d.reference), faceOf(d.candidate)].join(
|
|
172
|
+
' | ',
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Every journey a finding shows up on. Findings hold differences, and each
|
|
178
|
+
* difference knows its own journey, so this is derived rather than stored — one
|
|
179
|
+
* fewer field that can disagree with the list beside it.
|
|
180
|
+
*
|
|
181
|
+
* @param {Finding} finding
|
|
182
|
+
* @returns {string[]}
|
|
183
|
+
*/
|
|
184
|
+
export function journeysOf(finding) {
|
|
185
|
+
return unique(finding.differences.map((d) => d.journey));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {string} signature
|
|
190
|
+
* @param {Difference[]} members
|
|
191
|
+
* @param {{from: string, to: string}|undefined} rename
|
|
192
|
+
* @param {Record<string, string>} sources
|
|
193
|
+
* @returns {Finding}
|
|
194
|
+
*/
|
|
195
|
+
function buildFinding(signature, members, rename, sources) {
|
|
196
|
+
const head = members[0];
|
|
197
|
+
const nearFiles = unique(members.map((m) => sources[m.path])).slice(0, KEEP_NEAR_FILES);
|
|
198
|
+
const evidence = members.find((m) => typeof m.evidence === 'string' && m.evidence.length > 0)?.evidence;
|
|
199
|
+
// Half the differences in a rename are the "vanished" side, so the count of
|
|
200
|
+
// places is the count of pairs, not of rows.
|
|
201
|
+
const count = rename ? Math.max(1, Math.round(members.length / 2)) : members.length;
|
|
202
|
+
|
|
203
|
+
/** @type {Finding} */
|
|
204
|
+
const finding = {
|
|
205
|
+
id: shortHash(sha256(signature)),
|
|
206
|
+
title: describe(head, count, rename),
|
|
207
|
+
// Provisional. rank.js replaces this once it knows how far this sits from
|
|
208
|
+
// the edit, which is the only thing that makes the sentence worth reading.
|
|
209
|
+
why: 'Not yet worked out.',
|
|
210
|
+
class: 'ordinary',
|
|
211
|
+
differences: members,
|
|
212
|
+
rank: 0,
|
|
213
|
+
count,
|
|
214
|
+
signature,
|
|
215
|
+
// The addresses, and one difference that stands for the rest. Both are read by
|
|
216
|
+
// everything downstream — the MCP reply lists them, and the self-check corpus
|
|
217
|
+
// matches its patterns against them — so they are filled in here rather than
|
|
218
|
+
// left for each reader to dig out of `differences` in its own way.
|
|
219
|
+
paths: members.map((m) => m.path).slice(0, KEEP_PATHS),
|
|
220
|
+
sample: head,
|
|
221
|
+
};
|
|
222
|
+
if (nearFiles.length > 0) finding.nearFiles = nearFiles;
|
|
223
|
+
if (evidence) finding.evidence = evidence;
|
|
224
|
+
return finding;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* One plain sentence. No addresses, no jargon, no test ids — the reader is an
|
|
229
|
+
* agent deciding whether to spend tokens on this, or a person deciding whether
|
|
230
|
+
* to care.
|
|
231
|
+
*
|
|
232
|
+
* @param {Difference} d
|
|
233
|
+
* @param {number} count
|
|
234
|
+
* @param {{from: string, to: string}} [rename]
|
|
235
|
+
* @returns {string}
|
|
236
|
+
*/
|
|
237
|
+
export function describe(d, count, rename) {
|
|
238
|
+
const where = CHANNEL_WORDS[d.channel] ?? 'Somewhere';
|
|
239
|
+
const name = smartLeaf(d.path);
|
|
240
|
+
const spread = count > 1 ? ` The same thing in ${count} places.` : '';
|
|
241
|
+
|
|
242
|
+
if (rename) return `${where}, "${rename.from}" is now called "${rename.to}".${spread}`;
|
|
243
|
+
|
|
244
|
+
switch (d.kind) {
|
|
245
|
+
case 'changed':
|
|
246
|
+
return `${where}, "${name}" is now ${describeValue(d.candidate)} where it was ${describeValue(d.reference)}.${spread}`;
|
|
247
|
+
case 'appeared':
|
|
248
|
+
return `${where}, "${name}" is there now and was not before. It says ${describeValue(d.candidate)}.${spread}`;
|
|
249
|
+
case 'vanished':
|
|
250
|
+
return `${where}, "${name}" is gone. It used to say ${describeValue(d.reference)}.${spread}`;
|
|
251
|
+
default:
|
|
252
|
+
return `${where}, "${name}" behaves differently.${spread}`;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
// Addresses and values, read the way a person would read them
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
/** @param {string} path */
|
|
261
|
+
export function leafOf(path) {
|
|
262
|
+
const parts = splitPath(path);
|
|
263
|
+
return parts.length > 0 ? parts[parts.length - 1] : String(path ?? '');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** @param {string} path */
|
|
267
|
+
export function parentOf(path) {
|
|
268
|
+
return splitPath(path).slice(0, -1).join('.');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The last segment, plus the one before it when the last is too vague to mean
|
|
273
|
+
* anything alone. "enabled" tells a reader nothing; "button:Save / enabled"
|
|
274
|
+
* tells them everything.
|
|
275
|
+
*
|
|
276
|
+
* @param {string} path
|
|
277
|
+
*/
|
|
278
|
+
export function smartLeaf(path) {
|
|
279
|
+
const parts = splitPath(path);
|
|
280
|
+
if (parts.length === 0) return String(path ?? '');
|
|
281
|
+
const last = parts[parts.length - 1];
|
|
282
|
+
if (parts.length > 1 && VAGUE.has(last.toLowerCase())) return `${parts[parts.length - 2]} / ${last}`;
|
|
283
|
+
return last;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Flatten the parts of a name that are always different anyway: row numbers,
|
|
288
|
+
* ids, hashes. Without this, two hundred rows of one table look like two hundred
|
|
289
|
+
* separate findings.
|
|
290
|
+
*
|
|
291
|
+
* @param {string} text
|
|
292
|
+
*/
|
|
293
|
+
export function generalise(text) {
|
|
294
|
+
return String(text ?? '')
|
|
295
|
+
.replace(/\b[0-9a-f]{8,}\b/gi, 'x')
|
|
296
|
+
.replace(/\d+/g, '#');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* A value reduced to its recognisable face, for grouping.
|
|
301
|
+
*
|
|
302
|
+
* Short values keep their exact text, because the exact move from one to the
|
|
303
|
+
* other is what makes two differences the same finding. Long values keep only
|
|
304
|
+
* their shape, because two long strings differing in the middle are still the
|
|
305
|
+
* same kind of change and nobody wants them listed one by one.
|
|
306
|
+
*
|
|
307
|
+
* @param {ObservedValue|undefined} value
|
|
308
|
+
* @returns {string}
|
|
309
|
+
*/
|
|
310
|
+
export function faceOf(value) {
|
|
311
|
+
if (value === undefined) return 'nothing';
|
|
312
|
+
if (value === null) return 'null';
|
|
313
|
+
const kind = typeof value;
|
|
314
|
+
if (kind === 'number' || kind === 'boolean') return String(value);
|
|
315
|
+
if (kind === 'string') {
|
|
316
|
+
const text = /** @type {string} */ (value);
|
|
317
|
+
return text.length <= 60 ? JSON.stringify(text) : `text of about ${bucket(text.length)} characters`;
|
|
318
|
+
}
|
|
319
|
+
if (Array.isArray(value)) return `list of ${bucket(value.length)}`;
|
|
320
|
+
return `{${Object.keys(/** @type {object} */ (value)).sort().join(',')}}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* A value written for a person to read in the middle of a sentence.
|
|
325
|
+
* @param {ObservedValue|undefined} value
|
|
326
|
+
* @returns {string}
|
|
327
|
+
*/
|
|
328
|
+
export function describeValue(value) {
|
|
329
|
+
if (value === undefined || value === null) return 'nothing';
|
|
330
|
+
const kind = typeof value;
|
|
331
|
+
if (kind === 'number' || kind === 'boolean') return String(value);
|
|
332
|
+
if (kind === 'string') {
|
|
333
|
+
const text = /** @type {string} */ (value);
|
|
334
|
+
if (text.length === 0) return 'empty';
|
|
335
|
+
return text.length <= 70 ? JSON.stringify(text) : `${JSON.stringify(text.slice(0, 67))} and more`;
|
|
336
|
+
}
|
|
337
|
+
if (Array.isArray(value)) return `a list of ${value.length}`;
|
|
338
|
+
const keys = Object.keys(/** @type {object} */ (value));
|
|
339
|
+
if (keys.length === 0) return 'an empty set of details';
|
|
340
|
+
return `a set of details (${keys.slice(0, 4).join(', ')}${keys.length > 4 ? ', and more' : ''})`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Sizes bucketed, so "a list of 41" and "a list of 47" group together while "a
|
|
345
|
+
* list of 3" stays on its own — small counts are usually the point, large ones
|
|
346
|
+
* usually are not.
|
|
347
|
+
*
|
|
348
|
+
* @param {number} n
|
|
349
|
+
*/
|
|
350
|
+
function bucket(n) {
|
|
351
|
+
if (n <= 3) return String(n);
|
|
352
|
+
if (n <= 10) return '4 to 10';
|
|
353
|
+
if (n <= 100) return '11 to 100';
|
|
354
|
+
if (n <= 1000) return 'a few hundred';
|
|
355
|
+
return 'thousands';
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* @param {(string|undefined)[]} values
|
|
360
|
+
* @returns {string[]}
|
|
361
|
+
*/
|
|
362
|
+
function unique(values) {
|
|
363
|
+
/** @type {string[]} */
|
|
364
|
+
const out = [];
|
|
365
|
+
const seen = new Set();
|
|
366
|
+
for (const v of values) {
|
|
367
|
+
if (typeof v !== 'string' || v.length === 0 || seen.has(v)) continue;
|
|
368
|
+
seen.add(v);
|
|
369
|
+
out.push(v);
|
|
370
|
+
}
|
|
371
|
+
return out;
|
|
372
|
+
}
|