staysfixed 0.3.1 → 0.6.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +159 -3
  2. package/README.md +611 -402
  3. package/package.json +8 -3
  4. package/src/cli/index.js +14 -0
  5. package/src/v2/adapters/android-driver.js +1705 -0
  6. package/src/v2/adapters/android.js +1117 -0
  7. package/src/v2/adapters/contract.js +643 -0
  8. package/src/v2/adapters/electron.js +1594 -0
  9. package/src/v2/adapters/http.js +734 -0
  10. package/src/v2/adapters/ios-driver.js +1551 -0
  11. package/src/v2/adapters/ios.js +989 -0
  12. package/src/v2/adapters/isolate.js +739 -0
  13. package/src/v2/adapters/process.js +931 -0
  14. package/src/v2/adapters/source.js +1292 -0
  15. package/src/v2/adapters/web-driver.js +1532 -0
  16. package/src/v2/adapters/web.js +1009 -0
  17. package/src/v2/adapters/windows.js +1329 -0
  18. package/src/v2/browsers.js +1203 -0
  19. package/src/v2/cause.js +371 -0
  20. package/src/v2/check.js +1429 -0
  21. package/src/v2/ci.js +1209 -0
  22. package/src/v2/cli.js +670 -0
  23. package/src/v2/cluster.js +372 -0
  24. package/src/v2/coverage.js +1124 -0
  25. package/src/v2/detect.js +1199 -0
  26. package/src/v2/doctor.js +1702 -0
  27. package/src/v2/escalate.js +679 -0
  28. package/src/v2/init.js +1394 -0
  29. package/src/v2/intent.js +659 -0
  30. package/src/v2/journeys/from-routes.js +500 -0
  31. package/src/v2/journeys/from-suite.js +988 -0
  32. package/src/v2/journeys/index.js +651 -0
  33. package/src/v2/journeys/record.js +516 -0
  34. package/src/v2/mcp/server.js +374 -0
  35. package/src/v2/mcp/tools.js +1571 -0
  36. package/src/v2/normalise.js +783 -0
  37. package/src/v2/observation.js +938 -0
  38. package/src/v2/rank.js +672 -0
  39. package/src/v2/reference.js +1051 -0
  40. package/src/v2/remote.js +910 -0
  41. package/src/v2/run.js +1080 -0
  42. package/src/v2/sealed.js +568 -0
  43. package/src/v2/selfcheck.js +729 -0
  44. package/src/v2/ship.js +684 -0
  45. package/src/v2/store.js +703 -0
  46. package/src/v2/types.js +509 -0
  47. package/src/v2/waiver.js +511 -0
  48. package/src/v2/watch/focus.js +215 -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
+ }