staysfixed 0.7.2 → 0.9.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 (65) hide show
  1. package/CHANGELOG.md +429 -0
  2. package/README.md +193 -57
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +19 -6
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +564 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/status.js +45 -1
  15. package/src/cli/watch-flags.js +54 -0
  16. package/src/core/config.js +54 -3
  17. package/src/core/paths.js +15 -0
  18. package/src/guard/run.js +70 -3
  19. package/src/report/console.js +50 -6
  20. package/src/run.js +11 -0
  21. package/src/types.js +3 -0
  22. package/src/v2/adapters/android-driver.js +6 -1
  23. package/src/v2/adapters/android.js +97 -2
  24. package/src/v2/adapters/child.js +101 -0
  25. package/src/v2/adapters/contract.js +42 -5
  26. package/src/v2/adapters/electron.js +72 -6
  27. package/src/v2/adapters/http.js +18 -11
  28. package/src/v2/adapters/ios-driver.js +64 -14
  29. package/src/v2/adapters/ios.js +247 -25
  30. package/src/v2/adapters/process.js +783 -71
  31. package/src/v2/adapters/python.js +495 -0
  32. package/src/v2/adapters/source.js +373 -18
  33. package/src/v2/adapters/web-driver.js +134 -24
  34. package/src/v2/adapters/web.js +149 -18
  35. package/src/v2/adapters/windows.js +18 -1
  36. package/src/v2/browsers.js +66 -3
  37. package/src/v2/cause.js +61 -17
  38. package/src/v2/check.js +653 -69
  39. package/src/v2/ci.js +130 -35
  40. package/src/v2/cli.js +65 -42
  41. package/src/v2/cluster.js +220 -14
  42. package/src/v2/coverage.js +43 -176
  43. package/src/v2/detect.js +308 -60
  44. package/src/v2/doctor.js +353 -54
  45. package/src/v2/escalate.js +5 -1
  46. package/src/v2/init.js +183 -66
  47. package/src/v2/intent.js +9 -23
  48. package/src/v2/journeys/from-suite.js +336 -30
  49. package/src/v2/journeys/index.js +99 -6
  50. package/src/v2/mcp/tools.js +90 -16
  51. package/src/v2/normalise.js +169 -23
  52. package/src/v2/observation.js +19 -33
  53. package/src/v2/rank.js +216 -23
  54. package/src/v2/reference.js +160 -24
  55. package/src/v2/remote.js +113 -18
  56. package/src/v2/run.js +103 -14
  57. package/src/v2/sealed.js +0 -20
  58. package/src/v2/selfcheck.js +190 -13
  59. package/src/v2/ship.js +55 -5
  60. package/src/v2/store.js +67 -1
  61. package/src/v2/types.js +12 -2
  62. package/src/v2/waiver.js +64 -54
  63. package/src/v2/watch/events.js +60 -215
  64. package/src/v2/watch/focus.js +14 -4
  65. package/src/v2/watch/panel.js +167 -17
@@ -43,6 +43,7 @@
43
43
  * it rather than quietly walking a thinner path.
44
44
  */
45
45
 
46
+ import crypto from 'node:crypto';
46
47
  import fsp from 'node:fs/promises';
47
48
  import path from 'node:path';
48
49
 
@@ -103,6 +104,44 @@ const ready = new Map();
103
104
  // Reading the doors out of the source
104
105
  // ---------------------------------------------------------------------------
105
106
 
107
+ /**
108
+ * How many named controls are collected before the search gives up.
109
+ *
110
+ * It protects memory and the size of a capture: every one of these becomes an address that is
111
+ * stored twice per run. Four thousand is far more than any app this has been pointed at
112
+ * declares. If it is ever wrong the run does not go quiet — hitting it is reported as a hole
113
+ * and the count is described as a floor rather than a total.
114
+ */
115
+ const MOST_DOORS = 4000;
116
+
117
+ /**
118
+ * How deep into the project's folders the search for named controls goes.
119
+ *
120
+ * Symbolic links are not followed — `entry.isDirectory()` is false for one — so this is not
121
+ * protecting against a loop; it is protecting against spending a long time in a tree where
122
+ * the interesting code is nowhere near the bottom. Twenty-four is deeper than any iPhone
123
+ * project seen so far, and it used to be twelve, which a monorepo passes on its way to the
124
+ * app. Whatever is left out at this depth is named.
125
+ */
126
+ const DEEPEST_SOURCE_FOLDER = 24;
127
+
128
+ /**
129
+ * Why something would not open, in words somebody can act on. A bare `EACCES` sends people
130
+ * looking for a bug in this tool.
131
+ *
132
+ * @param {unknown} error
133
+ * @returns {string}
134
+ */
135
+ function whyNotOpened(error) {
136
+ const code = String(/** @type {any} */ (error)?.code ?? '');
137
+ if (code === 'EACCES' || code === 'EPERM') return 'this account does not have permission to open it';
138
+ if (code === 'ENOENT') return 'it was there when the walk started and is not there now';
139
+ if (code === 'ENOTDIR') return 'something in the way is a file, not a folder';
140
+ if (code === 'ELOOP') return 'the links in it point round in a circle';
141
+ const said = String(/** @type {any} */ (error)?.message ?? error ?? '').trim();
142
+ return said === '' ? 'the reason was not given' : said;
143
+ }
144
+
106
145
  /**
107
146
  * Every control the app declares by name, read straight out of the code.
108
147
  *
@@ -112,31 +151,56 @@ const ready = new Map();
112
151
  * of what the app was built to expose, which is the only honest denominator for "how deep
113
152
  * is this check really".
114
153
  *
154
+ * EVERY ONE OF THE LIMITS HERE IS REPORTED. There are three — how deep the folders go, how
155
+ * many controls are collected, and anything that would not open — and until 2026-08-30 all
156
+ * three ended with a bare `return` or `continue`. A run that stopped at four thousand
157
+ * controls, or at a folder it had no permission on, handed back a list that looked exactly
158
+ * like a complete one, and the ledger counted its coverage against a denominator that was
159
+ * quietly wrong. Whatever is left out now comes back on `limits`, in sentences, and the
160
+ * caller turns each one into a hole.
161
+ *
115
162
  * @param {string} root
116
- * @param {{limit?: number}} [opts]
117
- * @returns {Promise<{doors: {id: string, file: string, line: number}[], filesRead: number, tests: string[]}>}
163
+ * @param {{limit?: number, deepest?: number}} [opts]
164
+ * @returns {Promise<{doors: {id: string, file: string, line: number}[], filesRead: number, tests: string[], limits: string[]}>}
118
165
  */
119
166
  export async function readDeclaredDoors(root, opts = {}) {
120
- const limit = opts.limit ?? 4000;
167
+ const limit = opts.limit ?? MOST_DOORS;
168
+ const deepest = opts.deepest ?? DEEPEST_SOURCE_FOLDER;
121
169
  /** @type {{id: string, file: string, line: number}[]} */
122
170
  const doors = [];
123
171
  /** @type {string[]} */
124
172
  const tests = [];
173
+ /** @type {string[]} */
174
+ const limits = [];
175
+ /** @type {string[]} */
176
+ const tooDeep = [];
125
177
  let filesRead = 0;
178
+ let stoppedAtLimit = false;
126
179
  const skip = new Set(['node_modules', '.git', 'Pods', 'Carthage', 'DerivedData', 'build', '.build', 'dist', 'vendor']);
127
180
 
128
181
  /** @param {string} dir @param {number} depth */
129
182
  const walk = async (dir, depth) => {
130
- if (depth > 12 || doors.length > limit) return;
183
+ if (doors.length > limit) {
184
+ stoppedAtLimit = true;
185
+ return;
186
+ }
187
+ if (depth > deepest) {
188
+ tooDeep.push(path.relative(root, dir) || '.');
189
+ return;
190
+ }
131
191
  /** @type {import('node:fs').Dirent[]} */
132
192
  let entries = [];
133
193
  try {
134
194
  entries = await fsp.readdir(dir, { withFileTypes: true });
135
- } catch {
195
+ } catch (error) {
196
+ limits.push(`"${path.relative(root, dir) || '.'}" could not be opened — ${whyNotOpened(error)} — so any control named anywhere inside it is invisible to this run.`);
136
197
  return;
137
198
  }
138
199
  for (const entry of entries) {
139
- if (doors.length > limit) return;
200
+ if (doors.length > limit) {
201
+ stoppedAtLimit = true;
202
+ return;
203
+ }
140
204
  const full = path.join(dir, entry.name);
141
205
  if (entry.isDirectory()) {
142
206
  if (skip.has(entry.name) || entry.name.startsWith('.') || entry.name.endsWith('.app')) continue;
@@ -148,7 +212,8 @@ export async function readDeclaredDoors(root, opts = {}) {
148
212
  let text = '';
149
213
  try {
150
214
  text = await fsp.readFile(full, 'utf8');
151
- } catch {
215
+ } catch (error) {
216
+ limits.push(`"${path.relative(root, full)}" could not be read — ${whyNotOpened(error)} — so any control it names is invisible to this run.`);
152
217
  continue;
153
218
  }
154
219
  filesRead += 1;
@@ -171,13 +236,37 @@ export async function readDeclaredDoors(root, opts = {}) {
171
236
  };
172
237
  await walk(root, 0);
173
238
 
239
+ if (stoppedAtLimit) {
240
+ limits.push(
241
+ `The search stopped after ${limit} named controls, so any beyond that were not read. ` +
242
+ 'The count of controls this app declares is therefore a floor, not a total, and the share of them a walk reached is measured against the wrong denominator.'
243
+ );
244
+ }
245
+ if (tooDeep.length > 0) {
246
+ limits.push(
247
+ `${tooDeep.length} folder${tooDeep.length === 1 ? '' : 's'} sat more than ${deepest} deep and ${tooDeep.length === 1 ? 'was' : 'were'} not looked in: ` +
248
+ `${tooDeep.slice(0, 3).join(', ')}${tooDeep.length > 3 ? `, and ${tooDeep.length - 3} more` : ''}. Any control named inside is invisible to this run.`
249
+ );
250
+ }
251
+
174
252
  /** @type {Map<string, {id: string, file: string, line: number}>} */
175
253
  const unique = new Map();
176
254
  for (const door of doors) if (!unique.has(door.id)) unique.set(door.id, door);
177
- return { doors: [...unique.values()].sort((a, b) => a.id.localeCompare(b.id)), filesRead, tests: tests.sort() };
255
+ return { doors: [...unique.values()].sort((a, b) => a.id.localeCompare(b.id)), filesRead, tests: tests.sort(), limits };
178
256
  }
179
257
 
180
258
 
259
+ /**
260
+ * How long one piece of an address may be.
261
+ *
262
+ * A path has a length limit of its own and a label read off a screen can be a paragraph, so
263
+ * something has to give. What this protects against is one runaway label taking a whole run
264
+ * down; what would break if it were wrong is only how much of a name a reader sees, because
265
+ * anything cut off leaves a digest of the whole behind and two different things can never
266
+ * land on one address.
267
+ */
268
+ const LONGEST_SEGMENT = 160;
269
+
181
270
  /**
182
271
  * Make one piece of the app's own words safe to use as an address.
183
272
  *
@@ -188,15 +277,40 @@ export async function readDeclaredDoors(root, opts = {}) {
188
277
  * down, the words are trimmed, folded onto one line, and replaced with a plain description
189
278
  * when there is nothing left of them.
190
279
  *
280
+ * IT IS CUT WITH A FINGERPRINT, NEVER CUT ALONE. This used to end in `.slice(0, 160)`, and
281
+ * cutting an address merges two things into one: two log lines or two labels that agree for
282
+ * a hundred and sixty characters became one address, so the second answer written there was
283
+ * thrown away and whatever it said was never compared with anything. A short digest of the
284
+ * whole text goes on the end instead, so long-and-different stays different while
285
+ * long-and-identical stays identical. The desktop lane has done this since it was written;
286
+ * this is the same idea, and it should have been here from the start.
287
+ *
191
288
  * @param {unknown} text
192
289
  * @param {string} [whenEmpty]
193
290
  * @returns {string}
194
291
  */
195
292
  export function tidySegment(text, whenEmpty = 'unnamed') {
196
293
  const out = String(text ?? '').replace(/[\r\n\t]+/g, ' ').replace(/\s+/g, ' ').trim();
197
- return out === '' ? whenEmpty : out.slice(0, 160).trim();
294
+ if (out === '') return whenEmpty;
295
+ if (out.length <= LONGEST_SEGMENT) return out;
296
+ const mark = crypto.createHash('sha256').update(out).digest('hex').slice(0, 8);
297
+ return `${out.slice(0, LONGEST_SEGMENT - 12).trim()}… (${mark})`;
198
298
  }
199
299
 
300
+ /**
301
+ * How many built app bundles are collected before the search gives up, and how deep it goes.
302
+ *
303
+ * Neither protects against anything unbounded — symbolic links are not followed — so both are
304
+ * about time: a project with a large `build` folder can hold a great many bundles, and reading
305
+ * every one of them to pick the first is wasted work. Twelve deep clears a monorepo whose app
306
+ * sits under `apps/ios/build/Build/Products/Debug-iphonesimulator`; eight, which it used to
307
+ * be, does not. Hitting either is said out loud in the sentence this hands back.
308
+ */
309
+ const MOST_APP_CANDIDATES = 40;
310
+
311
+ /** @see MOST_APP_CANDIDATES */
312
+ const DEEPEST_APP_FOLDER = 12;
313
+
200
314
  /**
201
315
  * Find the built app.
202
316
  *
@@ -206,32 +320,51 @@ export function tidySegment(text, whenEmpty = 'unnamed') {
206
320
  * `.app` is looked for where builds land, and when there isn't one the answer is a clear
207
321
  * sentence saying which command would make one — never a silent skip.
208
322
  *
323
+ * WHERE IT STOPPED LOOKING IS PART OF THE ANSWER. The search gave up at eight folders deep
324
+ * or forty candidates and said nothing about either, so "no built iPhone app was found under
325
+ * this project" was said with equal confidence about a project with no app in it and about a
326
+ * monorepo whose app sits one folder past where the search turned round. Both of those end
327
+ * the same way — the phone is not checked — and only one of them is the person's fault.
328
+ *
209
329
  * @param {string} root
210
330
  * @param {Record<string, any>} [config]
211
- * @returns {Promise<{ok: boolean, appPath: string, why: string, candidates: string[]}>}
331
+ * @returns {Promise<{ok: boolean, appPath: string, why: string, candidates: string[], limits: string[]}>}
212
332
  */
213
333
  export async function findAppBundle(root, config = {}) {
214
334
  if (config.app) {
215
335
  const full = path.isAbsolute(config.app) ? config.app : path.join(root, config.app);
216
336
  try {
217
337
  await fsp.access(path.join(full, 'Info.plist'));
218
- return { ok: true, appPath: full, why: `Using the app named in the settings: ${full}`, candidates: [full] };
338
+ return { ok: true, appPath: full, why: `Using the app named in the settings: ${full}`, candidates: [full], limits: [] };
219
339
  } catch {
220
- return { ok: false, appPath: '', why: `The settings point at "${config.app}" but there is no iPhone app bundle there.`, candidates: [] };
340
+ return { ok: false, appPath: '', why: `The settings point at "${config.app}" but there is no iPhone app bundle there.`, candidates: [], limits: [] };
221
341
  }
222
342
  }
223
343
 
224
344
  /** @type {string[]} */
225
345
  const found = [];
346
+ /** @type {string[]} */
347
+ const limits = [];
348
+ /** @type {string[]} */
349
+ const tooDeep = [];
350
+ let stoppedAtLimit = false;
226
351
  const skip = new Set(['node_modules', '.git', 'Pods', 'Carthage']);
227
352
  /** @param {string} dir @param {number} depth */
228
353
  const walk = async (dir, depth) => {
229
- if (depth > 8 || found.length > 40) return;
354
+ if (found.length > MOST_APP_CANDIDATES) {
355
+ stoppedAtLimit = true;
356
+ return;
357
+ }
358
+ if (depth > DEEPEST_APP_FOLDER) {
359
+ tooDeep.push(path.relative(root, dir) || '.');
360
+ return;
361
+ }
230
362
  /** @type {import('node:fs').Dirent[]} */
231
363
  let entries = [];
232
364
  try {
233
365
  entries = await fsp.readdir(dir, { withFileTypes: true });
234
- } catch {
366
+ } catch (error) {
367
+ limits.push(`"${path.relative(root, dir) || '.'}" could not be opened — ${whyNotOpened(error)} — so a built app inside it was not found.`);
235
368
  return;
236
369
  }
237
370
  for (const entry of entries) {
@@ -252,23 +385,39 @@ export async function findAppBundle(root, config = {}) {
252
385
  };
253
386
  await walk(root, 0);
254
387
 
388
+ if (stoppedAtLimit) {
389
+ limits.push(`The search stopped after ${MOST_APP_CANDIDATES} app bundles, so anywhere it had not reached by then was not looked at.`);
390
+ }
391
+ if (tooDeep.length > 0) {
392
+ limits.push(
393
+ `${tooDeep.length} folder${tooDeep.length === 1 ? '' : 's'} sat more than ${DEEPEST_APP_FOLDER} deep and ${tooDeep.length === 1 ? 'was' : 'were'} not looked in: ` +
394
+ `${tooDeep.slice(0, 3).join(', ')}${tooDeep.length > 3 ? `, and ${tooDeep.length - 3} more` : ''}.`
395
+ );
396
+ }
397
+ // Where the search stopped goes into the sentence, always. "Nothing was found" and "nothing
398
+ // was found where I looked" are different answers, and only the second one tells somebody
399
+ // to point at the app by hand.
400
+ const said = limits.length > 0 ? ` Where this search stopped: ${limits.join(' ')}` : '';
401
+
255
402
  const simulatorBuilds = found.filter((f) => /iphonesimulator|Debug-iphonesimulator|Release-iphonesimulator|Build\/Products/i.test(f));
256
403
  const pick = simulatorBuilds[0] ?? found[0];
257
404
  if (!pick) {
258
405
  return {
259
406
  ok: false,
260
407
  appPath: '',
261
- why: 'No built iPhone app was found under this project. This adapter never builds one itself, because building somebody else\'s Xcode project with the wrong scheme produces a build that is not the one they meant.',
408
+ why: `No built iPhone app was found under this project. This adapter never builds one itself, because building somebody else's Xcode project with the wrong scheme produces a build that is not the one they meant.${said}`,
262
409
  candidates: [],
410
+ limits,
263
411
  };
264
412
  }
265
413
  return {
266
414
  ok: true,
267
415
  appPath: pick,
268
- why: found.length === 1
416
+ why: (found.length === 1
269
417
  ? `Found one built app: ${path.basename(pick)}.`
270
- : `Found ${found.length} built apps and picked the simulator one: ${path.basename(pick)}. Name a different one with {"app": "..."} in the settings.`,
418
+ : `Found ${found.length} built apps and picked the simulator one: ${path.basename(pick)}. Name a different one with {"app": "..."} in the settings.`) + said,
271
419
  candidates: found,
420
+ limits,
272
421
  };
273
422
  }
274
423
 
@@ -360,7 +509,15 @@ export function journeysFrom(input) {
360
509
  * @returns {string}
361
510
  */
362
511
  function safeName(name) {
363
- return String(name).trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'a-walk';
512
+ const clean = String(name).trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
513
+ if (clean === '') return 'a-walk';
514
+ if (clean.length <= 60) return clean;
515
+ // Two journeys whose names agree for sixty characters used to end up with one name between
516
+ // them, and a journey's name is the first segment of every address it writes: the second
517
+ // walk's answers landed on the first walk's addresses, and one of the two was thrown away
518
+ // at the door. The digest is of the whole name, so this cannot happen however long they are.
519
+ const mark = crypto.createHash('sha256').update(clean).digest('hex').slice(0, 8);
520
+ return `${clean.slice(0, 51)}-${mark}`;
364
521
  }
365
522
 
366
523
  // ---------------------------------------------------------------------------
@@ -437,7 +594,7 @@ export const iosAdapter = defineAdapter({
437
594
  });
438
595
  }
439
596
 
440
- const { doors, tests, filesRead } = await readDeclaredDoors(project.root);
597
+ const { doors, tests, filesRead, limits } = await readDeclaredDoors(project.root);
441
598
  /** @type {AppFacts|null} */
442
599
  let facts = null;
443
600
  if (found.ok) {
@@ -449,9 +606,21 @@ export const iosAdapter = defineAdapter({
449
606
  missing.push({
450
607
  what: `a way to run the app's own ${tests.length} interface test file${tests.length === 1 ? '' : 's'}`,
451
608
  unlocks: 'the best journeys this project has. They are already written, they already know how to sign in and get to the interesting screens, and nothing here is walking them',
452
- howToGet: `Run them once yourself with: xcodebuild test-without-building -scheme <YourScheme> -destination "platform=iOS Simulator,name=iPhone 16". Once that works, put {"suite": {"scheme": "<YourScheme>"}} under "ios" in the settings and they become journeys.`,
609
+ // NO SETTING IS OFFERED HERE, and that is the honest answer rather than a missing
610
+ // feature. This used to end "put {"suite": {"scheme": "..."}} under "ios" in the
611
+ // settings and they become journeys" — and nothing anywhere read `ios.suite`. Somebody
612
+ // following that sentence would have written the setting, seen the same message on the
613
+ // next run, and had no way at all to tell whether the tool or their spelling was at
614
+ // fault. A door that is painted on is worse than no door: it costs somebody an
615
+ // afternoon and it costs this tool the benefit of the doubt everywhere else.
616
+ howToGet:
617
+ 'There is nothing to switch on yet. Running these needs an Xcode build of the project — a scheme, signing settings, minutes of build time — and this adapter never builds anything, because building somebody else\'s project with a guessed scheme produces a build that is not the one they meant. ' +
618
+ 'What you can do today is run them yourself: xcodebuild test-without-building -scheme <YourScheme> -destination "platform=iOS Simulator,name=iPhone 16". They are found and counted here so the hole is visible in every run, and each one is reported as a journey that was not walked.',
453
619
  });
454
620
  }
621
+ for (const said of limits) {
622
+ notes.push(`${said} That makes the count of named controls above a floor rather than a total.`);
623
+ }
455
624
 
456
625
  const applies = Boolean(machine.isMac) && (found.ok || doors.length > 0 || Boolean(config.app));
457
626
  const withoutIdentifiers = doors.length === 0 && found.ok;
@@ -530,6 +699,9 @@ export const iosAdapter = defineAdapter({
530
699
  const doors = await readDeclaredDoors(build.root);
531
700
 
532
701
  ready.set(build.id, {
702
+ // Everything the two searches could not see, carried through to the walk so it lands in
703
+ // the coverage ledger rather than in a sentence nobody reads twice.
704
+ limits: [...doors.limits, ...found.limits],
533
705
  device: device.device,
534
706
  facts,
535
707
  appPath: found.appPath,
@@ -591,7 +763,7 @@ export const iosAdapter = defineAdapter({
591
763
  })];
592
764
  }
593
765
  if (journey.name === 'what-the-app-declares') {
594
- return declaredObservations(kept.facts, kept.doors, journey.name);
766
+ return declaredObservations(kept.facts, kept.doors, journey.name, kept.limits ?? []);
595
767
  }
596
768
 
597
769
  return walkObservations(journey, kept, ctx);
@@ -621,9 +793,13 @@ export const iosAdapter = defineAdapter({
621
793
  * @param {AppFacts} facts
622
794
  * @param {{id: string, file: string, line: number}[]} doors
623
795
  * @param {string} journey
796
+ * @param {string[]} [limits]
797
+ * Everything the searches that produced `doors` could not see. Each one becomes a hole,
798
+ * because the count below is the denominator the ledger measures a walk's depth against,
799
+ * and a denominator that quietly missed a folder flatters every run made against it.
624
800
  * @returns {Observation[]}
625
801
  */
626
- export function declaredObservations(facts, doors, journey) {
802
+ export function declaredObservations(facts, doors, journey, limits = []) {
627
803
  /** @type {Observation[]} */
628
804
  const out = [];
629
805
  const say = /** @param {string} text */ (text) => text;
@@ -684,9 +860,24 @@ export function declaredObservations(facts, doors, journey) {
684
860
  channel: 'counters', journey, surface: 'ios',
685
861
  path: joinPath('count', 'doors', 'declared'),
686
862
  value: countBucket(doors.length),
687
- says: say(`${doors.length} named control${doors.length === 1 ? '' : 's'} were read straight out of the code, without running anything.`),
863
+ says: say(
864
+ `${doors.length} named control${doors.length === 1 ? '' : 's'} were read straight out of the code, without running anything.` +
865
+ (limits.length > 0 ? ` That is a floor rather than a total: ${limits.length} thing${limits.length === 1 ? '' : 's'} the search could not see ${limits.length === 1 ? 'is' : 'are'} listed beside this.` : '')
866
+ ),
688
867
  }));
689
868
 
869
+ for (let i = 0; i < limits.length; i += 1) {
870
+ out.push(notCovered({
871
+ channel: 'contract',
872
+ // Numbered rather than named after the folder: the folder is inside the sentence, and an
873
+ // address built out of a path that differs between two machines would report itself as a
874
+ // difference on every run.
875
+ path: joinPath('contract', 'not read', String(i + 1)),
876
+ reason: 'not supported here',
877
+ says: limits[i],
878
+ }));
879
+ }
880
+
690
881
  return out;
691
882
  }
692
883
 
@@ -823,6 +1014,20 @@ async function walkObservations(journey, kept, ctx) {
823
1014
  }));
824
1015
  }
825
1016
 
1017
+ // Something on this screen sat deeper than the reader goes. Everything under it is
1018
+ // absent from `things` below, so without this line a control that was never looked at
1019
+ // and a control that is not there read exactly the same.
1020
+ if (settled.deeper > 0) {
1021
+ out.push(notCovered({
1022
+ channel: 'meaning',
1023
+ path: joinPath('screen', name, tidySegment(label, 'a checkpoint'), 'read all the way down'),
1024
+ reason: 'not supported here',
1025
+ says:
1026
+ `${settled.deeper} thing${settled.deeper === 1 ? '' : 's'} on this screen sat deeper than the reader goes, so ${settled.deeper === 1 ? 'it and everything under it' : 'they and everything under them'} were not read. ` +
1027
+ 'Nothing down there can be compared, tapped or typed into, and it is a hole rather than an empty part of the screen. A view hierarchy this deep is nearly always something nesting inside itself by mistake.',
1028
+ }));
1029
+ }
1030
+
826
1031
  for (const thing of things) {
827
1032
  out.push(observation({
828
1033
  channel: 'meaning', journey: name, surface: 'ios',
@@ -890,7 +1095,9 @@ async function walkObservations(journey, kept, ctx) {
890
1095
  says: `${traffic.calls.length} call${traffic.calls.length === 1 ? '' : 's'} went out during this walk, and ${traffic.refused.length} ${traffic.refused.length === 1 ? 'was' : 'were'} stopped.`,
891
1096
  }));
892
1097
 
893
- const files = await app.filesWritten();
1098
+ /** @type {{unreadable: string[]}} */
1099
+ const insideTheApp = { unreadable: [] };
1100
+ const files = await app.filesWritten(insideTheApp);
894
1101
  for (const file of files) {
895
1102
  out.push(observation({
896
1103
  channel: 'effects', journey: name, surface: 'ios',
@@ -905,6 +1112,17 @@ async function walkObservations(journey, kept, ctx) {
905
1112
  value: countBucket(files.length),
906
1113
  says: `${files.length} file${files.length === 1 ? '' : 's'} were left behind in the app's own folder.`,
907
1114
  }));
1115
+ if (insideTheApp.unreadable.length > 0) {
1116
+ out.push(notCovered({
1117
+ channel: 'effects',
1118
+ path: joinPath('count', name, 'folders in the app that would not open'),
1119
+ reason: 'not supported here',
1120
+ says:
1121
+ `${insideTheApp.unreadable.length} folder${insideTheApp.unreadable.length === 1 ? '' : 's'} inside the app's own space could not be opened, so nothing written in ${insideTheApp.unreadable.length === 1 ? 'it' : 'them'} was seen: ` +
1122
+ `${insideTheApp.unreadable.slice(0, 5).join(', ')}${insideTheApp.unreadable.length > 5 ? ', and more' : ''}. ` +
1123
+ 'The count above is therefore a floor. "The app wrote nothing there" and "nobody could look" are different answers.',
1124
+ }));
1125
+ }
908
1126
 
909
1127
  const complaints = await readAppLog({
910
1128
  udid: kept.device.udid,
@@ -924,7 +1142,11 @@ async function walkObservations(journey, kept, ctx) {
924
1142
  const [level, text] = key.split('|');
925
1143
  out.push(observation({
926
1144
  channel: 'complaints', journey: name, surface: 'ios',
927
- path: joinPath('log', name, tidySegment(level, 'said'), tidySegment(text.slice(0, 120), 'an empty line')),
1145
+ // The whole line, not its first hundred and twenty characters. `tidySegment` cuts it
1146
+ // to an address and leaves a digest of the rest behind; cutting it here first threw
1147
+ // that away, so two log lines agreeing for a hundred and twenty characters shared
1148
+ // one address and only one of them was ever compared.
1149
+ path: joinPath('log', name, tidySegment(level, 'said'), tidySegment(text, 'an empty line')),
928
1150
  value: countBucket(count),
929
1151
  says: `The app itself said "${text}"${count > 1 ? `, ${count} times` : ''}${level === 'error' || level === 'fault' ? ' — and it said it as an error' : ''}.`,
930
1152
  }));