staysfixed 0.1.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 (57) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/README.md +529 -0
  4. package/bin/staysfixed.js +18 -0
  5. package/examples/guards/the-sidebar-still-collapses.js +91 -0
  6. package/examples/staysfixed.config.electron.js +172 -0
  7. package/examples/staysfixed.config.web.js +277 -0
  8. package/package.json +61 -0
  9. package/src/cli/approve.js +126 -0
  10. package/src/cli/check.js +73 -0
  11. package/src/cli/doctor.js +379 -0
  12. package/src/cli/flake.js +61 -0
  13. package/src/cli/index.js +519 -0
  14. package/src/cli/init.js +564 -0
  15. package/src/cli/mark.js +69 -0
  16. package/src/cli/status.js +19 -0
  17. package/src/cli/trace.js +73 -0
  18. package/src/cli/walk.js +57 -0
  19. package/src/core/config.js +226 -0
  20. package/src/core/errors.js +48 -0
  21. package/src/core/git.js +90 -0
  22. package/src/core/hash.js +32 -0
  23. package/src/core/history.js +173 -0
  24. package/src/core/log.js +144 -0
  25. package/src/core/paths.js +135 -0
  26. package/src/drive/browser.js +540 -0
  27. package/src/drive/cdp.js +382 -0
  28. package/src/drive/electron.js +326 -0
  29. package/src/drive/find.js +331 -0
  30. package/src/drive/launch.js +263 -0
  31. package/src/drive/page.js +1042 -0
  32. package/src/freeze/clock.js +213 -0
  33. package/src/freeze/fonts.js +243 -0
  34. package/src/freeze/index.js +234 -0
  35. package/src/freeze/mask.js +187 -0
  36. package/src/freeze/motion.js +206 -0
  37. package/src/freeze/network.js +455 -0
  38. package/src/freeze/random.js +87 -0
  39. package/src/freeze/settle.js +178 -0
  40. package/src/guard/api.js +197 -0
  41. package/src/guard/load.js +324 -0
  42. package/src/guard/name.js +327 -0
  43. package/src/guard/run.js +224 -0
  44. package/src/index.js +61 -0
  45. package/src/marker/mark.js +260 -0
  46. package/src/marker/trace.js +293 -0
  47. package/src/mcp/server.js +377 -0
  48. package/src/mcp/tools.js +978 -0
  49. package/src/picture/capture.js +276 -0
  50. package/src/picture/compare.js +103 -0
  51. package/src/picture/run.js +284 -0
  52. package/src/picture/store.js +208 -0
  53. package/src/report/console.js +540 -0
  54. package/src/report/html.js +579 -0
  55. package/src/run.js +614 -0
  56. package/src/types.js +471 -0
  57. package/src/walk/run.js +541 -0
package/src/run.js ADDED
@@ -0,0 +1,614 @@
1
+ /**
2
+ * The engine.
3
+ *
4
+ * The CLI and the MCP server both come through here, which is the point: what a
5
+ * person sees when they type `staysfixed check` and what an agent sees when it
6
+ * calls `staysfixed_check` are the same run, decided by the same rules. If the
7
+ * two ever drift apart, the agent starts marking its own homework.
8
+ */
9
+
10
+ import fsp from 'node:fs/promises';
11
+ import path from 'node:path';
12
+ import { createRequire } from 'node:module';
13
+
14
+ import { StaysFixedError, messageOf } from './core/errors.js';
15
+ import { ensureDirs, clearResults, resultPicture, safeName } from './core/paths.js';
16
+ import { gitInfo } from './core/git.js';
17
+ import { loadHistory, saveHistory, foldRun, condemned } from './core/history.js';
18
+ import { warn, detail, shortPath } from './core/log.js';
19
+ import { launchApp } from './drive/launch.js';
20
+ import { platformTag } from './drive/find.js';
21
+ import { runPictures } from './picture/run.js';
22
+ import { approveFromResult, listApproved } from './picture/store.js';
23
+ import { loadGuards } from './guard/load.js';
24
+ import { runGuards } from './guard/run.js';
25
+ import { walkApp, writeWalkContactSheet } from './walk/run.js';
26
+ import { listMarkers } from './marker/mark.js';
27
+ import { writeRunReport } from './report/html.js';
28
+ import { printPictureResult, printGuardResult } from './report/console.js';
29
+
30
+ const require = createRequire(import.meta.url);
31
+
32
+ /** The version of the tool, read off package.json so it can never drift from what shipped. */
33
+ export const VERSION = /** @type {{version?: string}} */ (require('../package.json')).version ?? '0.0.0';
34
+
35
+ /** How a run stamps itself into pictures, markers and reports. */
36
+ const TOOL = `staysfixed ${VERSION}`;
37
+
38
+ /** The verdict of the last run, parked where `status` and `approve` can read it without re-running anything. */
39
+ const LAST_RUN = 'last-run.json';
40
+
41
+ /**
42
+ * @typedef {import('./picture/run.js').PictureRunResult} PictureRunResult
43
+ */
44
+
45
+ /**
46
+ * What `projectStatus` answers with. Nothing in here costs more than reading a
47
+ * few small files — `staysfixed status` has to be instant or nobody types it.
48
+ *
49
+ * @typedef {object} StatusReport
50
+ * @property {number} screens Screens described in the config.
51
+ * @property {number} guards Guards found on disk.
52
+ * @property {string|null} guardsError Why the guards could not be counted, in plain language.
53
+ * @property {number} approved Approved pictures on disk.
54
+ * @property {string[]} missingApproved Screens nobody has approved a picture for yet.
55
+ * @property {number} markers Known-good markers written so far.
56
+ * @property {{label: string, at: string}|null} lastMarker The newest marker, ready for the status printer.
57
+ * @property {import('./types.js').RunSummary|null} lastRun
58
+ * @property {string[]} condemned Checks that have flaked past the limit.
59
+ * @property {string} configFile
60
+ * @property {string} root
61
+ * @property {'web'|'electron'} appKind
62
+ */
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // check
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * Run every net the project has: photograph the screens, then run the guards.
70
+ *
71
+ * Unless `quiet` is set this prints one line per check as it finishes, so a
72
+ * person watching a long run sees it moving. It does not print the closing
73
+ * summary — the caller owns that, so the CLI can add its own next steps.
74
+ *
75
+ * @param {import('./types.js').Project} project
76
+ * @param {{
77
+ * only?: string[],
78
+ * picturesOnly?: boolean,
79
+ * guardsOnly?: boolean,
80
+ * record?: boolean,
81
+ * signal?: AbortSignal,
82
+ * quiet?: boolean,
83
+ * onPicture?: (result: import('./types.js').PictureResult) => void,
84
+ * onGuard?: (result: import('./types.js').GuardResult) => void,
85
+ * writeReport?: boolean,
86
+ * }} [opts]
87
+ * @returns {Promise<import('./types.js').RunSummary>}
88
+ */
89
+ export async function runCheck(project, opts = {}) {
90
+ const { config, paths } = project;
91
+ const startedAt = new Date();
92
+ const started = Date.now();
93
+
94
+ await ensureDirs(paths);
95
+ // Yesterday's evidence goes in the bin before today's is taken. A stale diff
96
+ // image sitting next to a fresh picture is the most convincing lie this tool
97
+ // could tell, and somebody would act on it.
98
+ await clearResults(paths);
99
+
100
+ const terms = normaliseOnly(opts.only);
101
+ const wantPictures = opts.guardsOnly !== true;
102
+ const wantGuards = opts.picturesOnly !== true;
103
+
104
+ // Guards are loaded before anything is launched, on purpose. A guard with a
105
+ // name nobody can read, or with no run function, is a mistake in the project
106
+ // — catching it now costs two seconds, catching it after a browser and a dev
107
+ // server have started costs two minutes of somebody's afternoon.
108
+ const allGuards = wantGuards ? await loadGuards(project) : [];
109
+ const guards = terms ? allGuards.filter((g) => terms.some((t) => matches(g.name, t))) : allGuards;
110
+
111
+ const allScreens = wantPictures ? config.screens : [];
112
+ const screens = terms ? allScreens.filter((s) => terms.some((t) => matches(s.name, t))) : allScreens;
113
+
114
+ if (terms && screens.length === 0 && guards.length === 0) {
115
+ throw new StaysFixedError(`Nothing here is called ${terms.map((t) => `"${t}"`).join(' or ')}.`, {
116
+ hint: nameHint(config, allGuards),
117
+ });
118
+ }
119
+
120
+ const onPicture = opts.onPicture ?? (opts.quiet ? undefined : (/** @type {import('./types.js').PictureResult} */ r) => printPictureResult(r));
121
+ const onGuard = opts.onGuard ?? (opts.quiet ? undefined : (/** @type {import('./types.js').GuardResult} */ r) => printGuardResult(r));
122
+
123
+ /** @type {PictureRunResult[]} */
124
+ let pictures = [];
125
+ /** @type {import('./types.js').GuardResult[]} */
126
+ let guardResults = [];
127
+
128
+ // Nothing to look at means nothing to open. Starting a browser to check zero
129
+ // screens is thirty seconds of somebody's life for no answer.
130
+ if (screens.length > 0 || guards.length > 0) {
131
+ await withApp(project, async (app) => {
132
+ if (screens.length > 0) {
133
+ pictures = await runPictures(project, app, {
134
+ only: screens.map((s) => s.name),
135
+ record: opts.record ?? false,
136
+ retries: config.retries,
137
+ tool: TOOL,
138
+ onResult: onPicture,
139
+ signal: opts.signal,
140
+ });
141
+ }
142
+ if (guards.length > 0) {
143
+ guardResults = await runGuards(project, app, guards, {
144
+ onResult: onGuard,
145
+ signal: opts.signal,
146
+ });
147
+ }
148
+ });
149
+ }
150
+
151
+ const git = await gitInfo(paths.root);
152
+ const finishedAt = new Date().toISOString();
153
+
154
+ /** @type {string[]} */
155
+ let condemnedNames = [];
156
+ try {
157
+ const history = await loadHistory(paths.historyFile);
158
+ const result = foldRun(history, foldable(pictures, guardResults), git, finishedAt, config.flakeLimit);
159
+ await saveHistory(paths.historyFile, result.history);
160
+ condemnedNames = condemned(result.history).map((e) => e.name);
161
+ for (const name of result.newlyCondemned) {
162
+ warn(
163
+ `"${name}" has now changed its mind ${config.flakeLimit} times while the code stood still. ` +
164
+ 'Fix it or delete it — a check people re-run until it goes green is worse than no check at all.',
165
+ );
166
+ }
167
+ } catch (e) {
168
+ // The register is a nice-to-have. Losing it must never lose the verdict.
169
+ warn(`The run finished, but the flaky-check register could not be updated. ${messageOf(e)}`);
170
+ }
171
+
172
+ const totals = countUp(pictures, guardResults);
173
+
174
+ /** @type {import('./types.js').RunSummary} */
175
+ const summary = {
176
+ id: runId(startedAt),
177
+ startedAt: startedAt.toISOString(),
178
+ durationMs: Date.now() - started,
179
+ pictures,
180
+ guards: guardResults,
181
+ totals,
182
+ // A picture nobody has ever approved is not a pass: the tool has no opinion
183
+ // about whether that screen is right, only a person does. So `new` keeps a
184
+ // run out of the green exactly the way `changed` does.
185
+ ok: totals.changed === 0 && totals.failed === 0 && totals.missing === 0 && totals.new === 0,
186
+ git,
187
+ tool: TOOL,
188
+ platform: platformTag(),
189
+ condemned: condemnedNames,
190
+ };
191
+
192
+ if (opts.writeReport !== false) {
193
+ try {
194
+ const file = await writeRunReport(project, summary);
195
+ detail(`Report written to ${shortPath(file)}`);
196
+ } catch (e) {
197
+ warn(`The run finished, but the report page could not be written. ${messageOf(e)}`);
198
+ }
199
+ }
200
+
201
+ try {
202
+ await fsp.writeFile(lastRunFile(paths), JSON.stringify(summary, null, 2) + '\n');
203
+ } catch (e) {
204
+ warn(`The run finished, but its result could not be saved for \`staysfixed status\`. ${messageOf(e)}`);
205
+ }
206
+
207
+ return summary;
208
+ }
209
+
210
+ // ---------------------------------------------------------------------------
211
+ // one screen
212
+ // ---------------------------------------------------------------------------
213
+
214
+ /**
215
+ * Open the app, photograph one screen, compare it if there is something to
216
+ * compare against, and shut down again.
217
+ *
218
+ * This is what an agent gets when it wants to look at what it just built. It
219
+ * writes the picture into `results/` like a normal run does, which is what makes
220
+ * `staysfixed approve <name>` work afterwards — the agent takes the photo, a
221
+ * person says yes to it.
222
+ *
223
+ * @param {import('./types.js').Project} project
224
+ * @param {string} screenName
225
+ * @param {{record?: boolean, retries?: number, signal?: AbortSignal, onResult?: (r: import('./types.js').PictureResult) => void}} [opts]
226
+ * @returns {Promise<{png: Buffer, result: import('./types.js').PictureResult, path: string}>}
227
+ */
228
+ export async function captureOne(project, screenName, opts = {}) {
229
+ const { config, paths } = project;
230
+ const wanted = String(screenName ?? '').trim();
231
+ const screen =
232
+ config.screens.find((s) => s.name === wanted) ??
233
+ config.screens.find((s) => s.name.toLowerCase() === wanted.toLowerCase());
234
+
235
+ if (!screen) {
236
+ throw new StaysFixedError(`There is no screen called "${wanted}" in this project.`, {
237
+ hint: nameHint(config, []),
238
+ });
239
+ }
240
+
241
+ await ensureDirs(paths);
242
+
243
+ const results = await withApp(project, (app) =>
244
+ runPictures(project, app, {
245
+ only: [screen.name],
246
+ record: opts.record ?? false,
247
+ retries: opts.retries ?? config.retries,
248
+ tool: TOOL,
249
+ onResult: opts.onResult,
250
+ signal: opts.signal,
251
+ }),
252
+ );
253
+
254
+ const result = results[0];
255
+ if (!result) {
256
+ throw new StaysFixedError(`"${screen.name}" was not photographed.`, {
257
+ hint: 'It may be switched off in the config with `skip: true`.',
258
+ });
259
+ }
260
+
261
+ const file = result.actualPath ?? resultPicture(paths, screen.name).png;
262
+ try {
263
+ return { png: await fsp.readFile(file), result, path: file };
264
+ } catch (cause) {
265
+ throw new StaysFixedError(result.message ?? `"${screen.name}" could not be photographed.`, { cause });
266
+ }
267
+ }
268
+
269
+ // ---------------------------------------------------------------------------
270
+ // walk
271
+ // ---------------------------------------------------------------------------
272
+
273
+ /**
274
+ * Open the real app, walk it end to end, and leave behind one page of photos
275
+ * anyone can open — the thing you look at before you press release.
276
+ *
277
+ * @param {import('./types.js').Project} project
278
+ * @param {{
279
+ * only?: string|string[],
280
+ * record?: boolean,
281
+ * signal?: AbortSignal,
282
+ * onStep?: (update: import('./walk/run.js').WalkProgress) => void,
283
+ * writeReport?: boolean,
284
+ * }} [opts]
285
+ * @returns {Promise<import('./types.js').WalkReport>}
286
+ */
287
+ export async function runWalk(project, opts = {}) {
288
+ await ensureDirs(project.paths);
289
+
290
+ const report = await withApp(project, (app) =>
291
+ walkApp(project, app, {
292
+ only: opts.only,
293
+ record: opts.record ?? false,
294
+ onStep: opts.onStep,
295
+ signal: opts.signal,
296
+ }),
297
+ );
298
+
299
+ if (opts.writeReport === false) return report;
300
+
301
+ try {
302
+ // Beside the photos, not in the project root: a walkthrough belongs with the
303
+ // pictures it is made of, and both are throwaway evidence.
304
+ const file = await writeWalkContactSheet(report, path.join(report.dir, 'walkthrough.html'));
305
+ return { ...report, reportFile: file };
306
+ } catch (e) {
307
+ warn(`The walkthrough was photographed, but the page showing it could not be written. ${messageOf(e)}`);
308
+ return report;
309
+ }
310
+ }
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // status
314
+ // ---------------------------------------------------------------------------
315
+
316
+ /**
317
+ * What this project has set up and where it stood after the last run.
318
+ *
319
+ * Deliberately opens nothing: `status` is the question you ask when you are not
320
+ * sure the tool is even wired up, and it has to answer immediately.
321
+ *
322
+ * @param {import('./types.js').Project} project
323
+ * @returns {Promise<StatusReport>}
324
+ */
325
+ export async function projectStatus(project) {
326
+ const { config, paths } = project;
327
+
328
+ let guardCount = 0;
329
+ /** @type {string|null} */
330
+ let guardsError = null;
331
+ try {
332
+ guardCount = (await loadGuards(project)).length;
333
+ } catch (e) {
334
+ // A broken guard file must not stop somebody finding out what else is here.
335
+ guardsError = messageOf(e);
336
+ }
337
+
338
+ const approved = await listApproved(paths).catch(() => /** @type {string[]} */ ([]));
339
+ const approvedSet = new Set(approved);
340
+ const missingApproved = config.screens
341
+ .filter((s) => !s.skip && !approvedSet.has(safeName(s.name)))
342
+ .map((s) => s.name);
343
+
344
+ const markers = await listMarkers(project).catch(() => /** @type {import('./types.js').Marker[]} */ ([]));
345
+ const history = await loadHistory(paths.historyFile);
346
+
347
+ return {
348
+ screens: config.screens.length,
349
+ guards: guardCount,
350
+ guardsError,
351
+ approved: approved.length,
352
+ missingApproved,
353
+ markers: markers.length,
354
+ lastMarker: markers.length > 0 ? { label: markers[0].label, at: markers[0].at } : null,
355
+ lastRun: await readLastRun(paths),
356
+ condemned: condemned(history).map((e) => e.name),
357
+ configFile: paths.configFile,
358
+ root: paths.root,
359
+ appKind: config.app.kind,
360
+ };
361
+ }
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // approve
365
+ // ---------------------------------------------------------------------------
366
+
367
+ /**
368
+ * Promote pictures from the last run to being the approved ones.
369
+ *
370
+ * This is the only door a new look walks through, and a person is always the one
371
+ * holding it open. Names with nothing behind them are refused with a reason
372
+ * rather than quietly ignored, because "approved" printed next to a name that
373
+ * was never actually approved is how trust in the whole tool goes.
374
+ *
375
+ * With no names (or `all`), everything the last run called changed or new is
376
+ * approved together.
377
+ *
378
+ * @param {import('./types.js').Project} project
379
+ * @param {string[]} names
380
+ * @param {{all?: boolean, tool?: string}} [opts]
381
+ * @returns {Promise<{approved: string[], skipped: {name: string, why: string}[]}>}
382
+ */
383
+ export async function approveScreens(project, names, opts = {}) {
384
+ const { config, paths } = project;
385
+ const last = await readLastRun(paths);
386
+
387
+ let wanted = unique((names ?? []).map((n) => String(n ?? '').trim()).filter(Boolean));
388
+
389
+ if (opts.all === true || wanted.length === 0) {
390
+ if (!last) {
391
+ throw new StaysFixedError('There is nothing to approve — no check has been run in this project yet.', {
392
+ hint: 'Run `staysfixed check` first, look at what it found, then approve what is right.',
393
+ });
394
+ }
395
+ wanted = unique(
396
+ last.pictures.filter((p) => p.status === 'changed' || p.status === 'new').map((p) => p.name),
397
+ );
398
+ }
399
+
400
+ /** @type {string[]} */
401
+ const approved = [];
402
+ /** @type {{name: string, why: string}[]} */
403
+ const skipped = [];
404
+
405
+ const git = await gitInfo(paths.root);
406
+ const tool = opts.tool ?? TOOL;
407
+
408
+ for (const asked of wanted) {
409
+ const screen =
410
+ config.screens.find((s) => s.name === asked) ??
411
+ config.screens.find((s) => s.name.toLowerCase() === asked.toLowerCase());
412
+ const name = screen ? screen.name : asked;
413
+
414
+ if (!(await exists(resultPicture(paths, name).png))) {
415
+ skipped.push({
416
+ name,
417
+ why: screen
418
+ ? `There is no picture of "${name}" from the last run, so there is nothing to approve. Run \`staysfixed check\` first.`
419
+ : `Nothing here is called "${name}", and there is no picture of it from the last run either.`,
420
+ });
421
+ continue;
422
+ }
423
+
424
+ try {
425
+ await approveFromResult(paths, name, { git, tool, describe: screen?.describe });
426
+ approved.push(name);
427
+ } catch (e) {
428
+ skipped.push({ name, why: messageOf(e) });
429
+ }
430
+ }
431
+
432
+ return { approved, skipped };
433
+ }
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // the plumbing
437
+ // ---------------------------------------------------------------------------
438
+
439
+ /**
440
+ * Open the app, do the work, and always close it again — a browser or an
441
+ * Electron window left running is a leaked process on somebody's machine, and
442
+ * the next run will fight it for the debug port.
443
+ *
444
+ * @template T
445
+ * @param {import('./types.js').Project} project
446
+ * @param {(app: import('./types.js').LaunchedApp) => Promise<T>} work
447
+ * @returns {Promise<T>}
448
+ */
449
+ async function withApp(project, work) {
450
+ const app = await launchApp(project);
451
+ try {
452
+ return await work(app);
453
+ } finally {
454
+ try {
455
+ await app.close();
456
+ } catch (e) {
457
+ warn(`The app could not be closed cleanly. ${messageOf(e)}`);
458
+ }
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Flatten both kinds of result into the one shape the flake register folds.
464
+ * @param {PictureRunResult[]} pictures
465
+ * @param {import('./types.js').GuardResult[]} guards
466
+ * @returns {{name: string, kind: 'picture'|'guard', status: import('./types.js').CheckStatus, retriedToPass?: boolean}[]}
467
+ */
468
+ function foldable(pictures, guards) {
469
+ return [
470
+ ...pictures.map((p) => ({
471
+ name: p.name,
472
+ kind: /** @type {'picture'} */ ('picture'),
473
+ status: p.status,
474
+ retriedToPass: p.retriedToPass,
475
+ })),
476
+ ...guards.map((g) => ({
477
+ name: g.name,
478
+ kind: /** @type {'guard'} */ ('guard'),
479
+ status: g.status,
480
+ // The guard runner records this the same way the picture runner does; it
481
+ // just is not part of the shared result shape.
482
+ retriedToPass: /** @type {{retriedToPass?: boolean}} */ (g).retriedToPass,
483
+ })),
484
+ ];
485
+ }
486
+
487
+ /**
488
+ * @param {PictureRunResult[]} pictures
489
+ * @param {import('./types.js').GuardResult[]} guards
490
+ * @returns {import('./types.js').RunSummary['totals']}
491
+ */
492
+ function countUp(pictures, guards) {
493
+ const totals = { passed: 0, changed: 0, new: 0, failed: 0, missing: 0, skipped: 0 };
494
+ for (const status of [...pictures.map((p) => p.status), ...guards.map((g) => g.status)]) {
495
+ switch (status) {
496
+ case 'passed':
497
+ totals.passed += 1;
498
+ break;
499
+ case 'changed':
500
+ totals.changed += 1;
501
+ break;
502
+ case 'new':
503
+ totals.new += 1;
504
+ break;
505
+ case 'failed':
506
+ totals.failed += 1;
507
+ break;
508
+ case 'missing':
509
+ totals.missing += 1;
510
+ break;
511
+ case 'skipped':
512
+ totals.skipped += 1;
513
+ break;
514
+ // 'flaky' is what the history says about a check over time, never a
515
+ // verdict one run hands down, so it is not a column here.
516
+ case 'flaky':
517
+ break;
518
+ }
519
+ }
520
+ return totals;
521
+ }
522
+
523
+ /**
524
+ * A sortable stamp off the local clock, so runs read in order in a folder
525
+ * listing and a person recognises the time they ran it.
526
+ * @param {Date} at
527
+ * @returns {string}
528
+ */
529
+ function runId(at) {
530
+ const pad = (/** @type {number} */ n) => String(n).padStart(2, '0');
531
+ return (
532
+ `${at.getFullYear()}${pad(at.getMonth() + 1)}${pad(at.getDate())}` +
533
+ `-${pad(at.getHours())}${pad(at.getMinutes())}${pad(at.getSeconds())}`
534
+ );
535
+ }
536
+
537
+ /**
538
+ * @param {string[]|undefined} only
539
+ * @returns {string[]|null}
540
+ */
541
+ function normaliseOnly(only) {
542
+ if (!only) return null;
543
+ const terms = only.map((t) => String(t ?? '').trim()).filter(Boolean);
544
+ return terms.length > 0 ? terms : null;
545
+ }
546
+
547
+ /**
548
+ * `--only sessions` should find "sessions-empty" as well as "sessions", because
549
+ * nobody types a screen name out exactly, and nobody should have to.
550
+ * @param {string} name
551
+ * @param {string} term
552
+ * @returns {boolean}
553
+ */
554
+ function matches(name, term) {
555
+ const haystack = name.toLowerCase();
556
+ const needle = term.toLowerCase();
557
+ return haystack === needle || haystack.includes(needle);
558
+ }
559
+
560
+ /**
561
+ * @param {import('./types.js').ResolvedConfig} config
562
+ * @param {import('./types.js').Guard[]} guards
563
+ * @returns {string}
564
+ */
565
+ function nameHint(config, guards) {
566
+ const screens = config.screens.map((s) => s.name);
567
+ const parts = [];
568
+ if (screens.length > 0) parts.push(`Screens: ${screens.join(', ')}.`);
569
+ if (guards.length > 0) parts.push(`Guards: ${guards.map((g) => `"${g.name}"`).join(', ')}.`);
570
+ if (parts.length === 0) return 'This project has no screens and no guards yet.';
571
+ return parts.join(' ');
572
+ }
573
+
574
+ /**
575
+ * @param {import('./types.js').ProjectPaths} paths
576
+ * @returns {string}
577
+ */
578
+ function lastRunFile(paths) {
579
+ return path.join(paths.results, LAST_RUN);
580
+ }
581
+
582
+ /**
583
+ * @param {import('./types.js').ProjectPaths} paths
584
+ * @returns {Promise<import('./types.js').RunSummary|null>}
585
+ */
586
+ async function readLastRun(paths) {
587
+ try {
588
+ const parsed = JSON.parse(await fsp.readFile(lastRunFile(paths), 'utf8'));
589
+ return parsed && typeof parsed === 'object' ? /** @type {import('./types.js').RunSummary} */ (parsed) : null;
590
+ } catch {
591
+ return null;
592
+ }
593
+ }
594
+
595
+ /**
596
+ * @param {string} file
597
+ * @returns {Promise<boolean>}
598
+ */
599
+ async function exists(file) {
600
+ try {
601
+ await fsp.access(file);
602
+ return true;
603
+ } catch {
604
+ return false;
605
+ }
606
+ }
607
+
608
+ /**
609
+ * @param {string[]} list
610
+ * @returns {string[]}
611
+ */
612
+ function unique(list) {
613
+ return [...new Set(list)];
614
+ }