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
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Waiting until the screen stops moving.
3
+ *
4
+ * Everything else in the freeze layer removes a *reason* for the page to change. This is
5
+ * the safety net for reasons we did not think of: a late render, a font swap, a chart
6
+ * drawing itself, a layout that reflows once the scrollbar decides whether to exist.
7
+ *
8
+ * The rule is simple and it is the whole reason picture checks can be trusted: take the
9
+ * photo, take it again, and only accept it once two photos in a row agree.
10
+ */
11
+
12
+ import { PNG } from 'pngjs';
13
+ import { StaysFixedError } from '../core/errors.js';
14
+ import { detail } from '../core/log.js';
15
+
16
+ /**
17
+ * @param {import('../types.js').PageHandle} page
18
+ * @param {{frames?: number, intervalMs?: number, timeoutMs?: number, maxDriftPixels?: number, capture: () => Promise<Buffer>}} opts
19
+ * @returns {Promise<{report: import('../types.js').SettleReport, png: Buffer}>}
20
+ */
21
+ export async function settle(page, opts) {
22
+ const frames = Math.max(1, opts.frames ?? 2);
23
+ const intervalMs = Math.max(0, opts.intervalMs ?? 250);
24
+ const timeoutMs = Math.max(0, opts.timeoutMs ?? 10_000);
25
+ const maxDriftPixels = Math.max(0, opts.maxDriftPixels ?? 0);
26
+ const capture = opts.capture;
27
+
28
+ // Host-side Date.now, not the page's — the page's clock is frozen on purpose.
29
+ const started = Date.now();
30
+
31
+ await waitUntilQuiet(page, Math.min(timeoutMs, 5000));
32
+
33
+ /** @type {Buffer|null} */
34
+ let previous = null;
35
+ /** @type {unknown} */
36
+ let lastError = null;
37
+ let stable = 0;
38
+ let attempts = 0;
39
+ let lastDrift = 0;
40
+
41
+ for (;;) {
42
+ /** @type {Buffer|null} */
43
+ let shot = null;
44
+ try {
45
+ shot = await capture();
46
+ attempts += 1;
47
+ } catch (e) {
48
+ lastError = e;
49
+ }
50
+
51
+ if (shot) {
52
+ if (!previous) {
53
+ stable = 1;
54
+ lastDrift = 0;
55
+ } else {
56
+ lastDrift = driftBetween(previous, shot);
57
+ stable = lastDrift <= maxDriftPixels ? stable + 1 : 1;
58
+ }
59
+ previous = shot;
60
+
61
+ if (stable >= frames) {
62
+ return {
63
+ report: { settled: true, attempts, lastDriftPixels: lastDrift, waitedMs: Date.now() - started },
64
+ png: shot,
65
+ };
66
+ }
67
+ }
68
+
69
+ if (Date.now() - started >= timeoutMs) break;
70
+ if (intervalMs > 0) await sleep(intervalMs);
71
+ }
72
+
73
+ if (!previous) {
74
+ // Nothing to hand back. This is the one case we do throw: a caller cannot decide
75
+ // anything about a photo that does not exist.
76
+ if (lastError instanceof Error) throw lastError;
77
+ throw new StaysFixedError('I could not take a picture of this screen at all.', {
78
+ hint: 'The window may have closed, or the app may have crashed mid-run.',
79
+ });
80
+ }
81
+
82
+ detail('settle: gave up after', String(attempts), 'tries;', String(lastDrift), 'pixels still moving');
83
+ return {
84
+ report: { settled: false, attempts, lastDriftPixels: lastDrift, waitedMs: Date.now() - started },
85
+ png: previous,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * How many pixels differ between two photos.
91
+ *
92
+ * The fast path is the one that runs almost every time: two settled photos are byte-for-
93
+ * byte identical, and comparing the compressed bytes costs nothing. Only when they differ
94
+ * do we pay to decode both.
95
+ *
96
+ * @param {Buffer} a
97
+ * @param {Buffer} b
98
+ * @returns {number}
99
+ */
100
+ function driftBetween(a, b) {
101
+ if (a.length === b.length && a.equals(b)) return 0;
102
+ try {
103
+ const pa = PNG.sync.read(a);
104
+ const pb = PNG.sync.read(b);
105
+ if (pa.width !== pb.width || pa.height !== pb.height) {
106
+ // The window resized between shots. That is movement by any definition.
107
+ return Number.MAX_SAFE_INTEGER;
108
+ }
109
+ const da = pa.data;
110
+ const db = pb.data;
111
+ let differing = 0;
112
+ for (let i = 0; i < da.length; i += 4) {
113
+ if (da[i] !== db[i] || da[i + 1] !== db[i + 1] || da[i + 2] !== db[i + 2] || da[i + 3] !== db[i + 3]) {
114
+ differing += 1;
115
+ }
116
+ }
117
+ return differing;
118
+ } catch {
119
+ // A truncated photo from a window that closed mid-capture. Treat it as maximum
120
+ // movement so we try again rather than accepting it.
121
+ return Number.MAX_SAFE_INTEGER;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Let the page finish what it was already doing before the first photo. Skipping this
127
+ * means the first two shots differ every time and the whole settle loop pays for it.
128
+ *
129
+ * @param {import('../types.js').PageHandle} page
130
+ * @param {number} timeoutMs
131
+ * @returns {Promise<void>}
132
+ */
133
+ async function waitUntilQuiet(page, timeoutMs) {
134
+ const source = `(async () => {
135
+ const LIMIT = ${Math.max(0, Math.round(timeoutMs))};
136
+
137
+ if (document.readyState !== 'complete') {
138
+ await Promise.race([
139
+ new Promise(function (r) { window.addEventListener('load', r, { once: true }); }),
140
+ new Promise(function (r) { setTimeout(r, LIMIT); })
141
+ ]);
142
+ }
143
+
144
+ // Anything the freeze layer missed gets a short grace period to end on its own. The
145
+ // count is bounded rather than timed because the page clock is frozen.
146
+ if (typeof document.getAnimations === 'function') {
147
+ for (var i = 0; i < 20; i++) {
148
+ var running = 0;
149
+ try { running = document.getAnimations().length; } catch (e) { running = 0; }
150
+ if (running === 0) break;
151
+ await new Promise(function (r) { setTimeout(r, 25); });
152
+ }
153
+ }
154
+
155
+ // Two frames, not one: the first lets the browser run whatever was scheduled, the
156
+ // second only arrives after that work has actually been painted.
157
+ await new Promise(function (r) {
158
+ requestAnimationFrame(function () { requestAnimationFrame(function () { r(undefined); }); });
159
+ });
160
+ return true;
161
+ })()`;
162
+
163
+ try {
164
+ await page.evaluate(source);
165
+ } catch {
166
+ // Not being able to ask the page is not a reason to skip the photo.
167
+ }
168
+ }
169
+
170
+ /**
171
+ * @param {number} ms
172
+ * @returns {Promise<void>}
173
+ */
174
+ function sleep(ms) {
175
+ return new Promise((resolve) => {
176
+ setTimeout(resolve, ms);
177
+ });
178
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * The surface a guard is handed.
3
+ *
4
+ * Everything here exists to make a failure readable a long time after the person
5
+ * who wrote it has forgotten the bug. That is why assertions are a sentence plus
6
+ * a check, and never a bare comparison: `expect('the sidebar is hidden', ...)`
7
+ * fails with "expected: the sidebar is hidden", which anyone can act on.
8
+ */
9
+
10
+ import { exec } from 'node:child_process';
11
+ import fsp from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { StaysFixedError } from '../core/errors.js';
14
+
15
+ /** A plain-language expectation that did not hold. */
16
+ export class ExpectationFailed extends Error {
17
+ /** @param {string} claim The sentence the guard wrote. */
18
+ constructor(claim) {
19
+ super(`expected: ${claim}`);
20
+ this.name = 'ExpectationFailed';
21
+ /** @type {string} */
22
+ this.claim = claim;
23
+ }
24
+ }
25
+
26
+ const DEFAULT_RUN_TIMEOUT = 60_000;
27
+
28
+ /** Commands can print a lot; 10MB before we cut them off. */
29
+ const MAX_OUTPUT = 10 * 1024 * 1024;
30
+
31
+ /**
32
+ * Build the object passed to a guard's `run`.
33
+ *
34
+ * @param {import('../types.js').PageApi} page
35
+ * @param {import('../types.js').Project} project
36
+ * @returns {import('../types.js').GuardApi}
37
+ */
38
+ export function makeGuardApi(page, project) {
39
+ const root = project.paths.root;
40
+
41
+ return {
42
+ page,
43
+ project,
44
+
45
+ /**
46
+ * @param {string} to
47
+ * @returns {Promise<void>}
48
+ */
49
+ open(to) {
50
+ return page.goto(to);
51
+ },
52
+
53
+ /**
54
+ * @param {string} selector
55
+ * @returns {Promise<void>}
56
+ */
57
+ click(selector) {
58
+ return page.click(selector);
59
+ },
60
+
61
+ /**
62
+ * @param {string} claim
63
+ * @param {() => unknown | Promise<unknown>} check
64
+ * @returns {Promise<void>}
65
+ */
66
+ async expect(claim, check) {
67
+ if (typeof claim !== 'string' || claim.trim() === '') {
68
+ throw new StaysFixedError('An expectation needs a sentence in front of it.', {
69
+ hint: 'Write it the way you would say it: expect("the sidebar is hidden", () => ...). That sentence is what a person reads when the guard fails.',
70
+ });
71
+ }
72
+ if (typeof check !== 'function') {
73
+ throw new StaysFixedError(`The expectation "${claim}" was not given anything to check.`, {
74
+ hint: 'Pass a function as the second argument: expect("the sidebar is hidden", async () => !(await page.visible(".sidebar"))).',
75
+ });
76
+ }
77
+
78
+ let result;
79
+ try {
80
+ result = await check();
81
+ } catch (cause) {
82
+ // A nested expectation already reads well — do not bury it in another layer.
83
+ if (cause instanceof ExpectationFailed) throw cause;
84
+ throw new Error(`while checking '${claim}': ${cause instanceof Error ? cause.message : String(cause)}`, {
85
+ cause,
86
+ });
87
+ }
88
+
89
+ if (isNegative(result)) throw new ExpectationFailed(claim);
90
+ },
91
+
92
+ /**
93
+ * Run a shell command. Guards that are not about the screen — a build that
94
+ * must still succeed, a file that must still be generated — live here.
95
+ *
96
+ * A non-zero exit is returned, never thrown: whether it means failure is the
97
+ * guard's decision, not ours.
98
+ *
99
+ * @param {string} cmd
100
+ * @param {{cwd?: string, timeoutMs?: number}} [runOpts]
101
+ * @returns {Promise<{code: number, stdout: string, stderr: string}>}
102
+ */
103
+ run(cmd, runOpts = {}) {
104
+ const cwd = runOpts.cwd ? path.resolve(root, runOpts.cwd) : root;
105
+ const timeoutMs = runOpts.timeoutMs ?? DEFAULT_RUN_TIMEOUT;
106
+
107
+ /** @type {Promise<{code: number, stdout: string, stderr: string}>} */
108
+ const finished = new Promise((resolve) => {
109
+ exec(
110
+ cmd,
111
+ { cwd, timeout: timeoutMs, maxBuffer: MAX_OUTPUT, encoding: 'utf8' },
112
+ (error, stdout, stderr) => {
113
+ const out = String(stdout ?? '');
114
+ let err = String(stderr ?? '');
115
+ let code = 0;
116
+
117
+ if (error) {
118
+ const e = /** @type {any} */ (error);
119
+ if (e.killed || e.signal) {
120
+ // 124 is what `timeout(1)` uses, so a guard can spot it.
121
+ code = 124;
122
+ err += `\n(the command was stopped after ${humanTime(timeoutMs)})`;
123
+ } else {
124
+ code = typeof e.code === 'number' ? e.code : 1;
125
+ }
126
+ }
127
+
128
+ resolve({ code, stdout: out, stderr: err });
129
+ },
130
+ );
131
+ });
132
+ return finished;
133
+ },
134
+
135
+ /**
136
+ * Read a file from the project.
137
+ *
138
+ * @param {string} file
139
+ * @returns {Promise<string>}
140
+ */
141
+ async read(file) {
142
+ const full = path.resolve(root, file);
143
+ const relative = path.relative(root, full);
144
+ // A guard belongs to one project; reading outside it makes the guard depend
145
+ // on whoever's machine it happens to be running on.
146
+ if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
147
+ throw new StaysFixedError(`A guard can only read files inside the project, and "${file}" is outside it.`, {
148
+ hint: 'Use a path relative to the project root, like "package.json" or "src/app.js".',
149
+ });
150
+ }
151
+
152
+ try {
153
+ return await fsp.readFile(full, 'utf8');
154
+ } catch (cause) {
155
+ const code = /** @type {any} */ (cause)?.code;
156
+ if (code === 'ENOENT') {
157
+ throw new StaysFixedError(`There is no file called "${relative}" in the project.`, { cause });
158
+ }
159
+ if (code === 'EISDIR') {
160
+ throw new StaysFixedError(`"${relative}" is a folder, not a file.`, { cause });
161
+ }
162
+ throw new StaysFixedError(`Could not read "${relative}": ${cause instanceof Error ? cause.message : String(cause)}`, {
163
+ cause,
164
+ });
165
+ }
166
+ },
167
+ };
168
+ }
169
+
170
+ /**
171
+ * What counts as "no".
172
+ *
173
+ * The empty array is in here because a check that gathers matches and finds none
174
+ * has not proved anything — `expect('the rows are there', () => findRows())`
175
+ * must fail on an empty list, not quietly pass. NaN is in here for the same
176
+ * reason: a measurement that went wrong must never be mistaken for a good one.
177
+ *
178
+ * @param {unknown} value
179
+ * @returns {boolean}
180
+ */
181
+ function isNegative(value) {
182
+ if (value === false || value === null || value === undefined) return true;
183
+ if (value === 0 || value === '') return true;
184
+ if (typeof value === 'number' && Number.isNaN(value)) return true;
185
+ if (Array.isArray(value) && value.length === 0) return true;
186
+ return false;
187
+ }
188
+
189
+ /**
190
+ * @param {number} ms
191
+ * @returns {string}
192
+ */
193
+ function humanTime(ms) {
194
+ if (ms < 1000) return `${Math.round(ms)} milliseconds`;
195
+ const seconds = Math.round(ms / 1000);
196
+ return seconds === 1 ? '1 second' : `${seconds} seconds`;
197
+ }
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Finding and loading a project's guards.
3
+ *
4
+ * Every problem found here is reported at once. Fixing guard names one error at
5
+ * a time, re-running between each, is the kind of chore that makes people delete
6
+ * the guards instead of naming them properly.
7
+ */
8
+
9
+ import fsp from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import { pathToFileURL } from 'node:url';
12
+ import { StaysFixedError } from '../core/errors.js';
13
+ import { shortPath } from '../core/log.js';
14
+ import { checkGuardName, NAME_RULE_EXPLAINER } from './name.js';
15
+
16
+ /**
17
+ * @typedef {object} GuardProblem
18
+ * @property {string} file
19
+ * @property {string} label What the guard called itself, quoted back.
20
+ * @property {string} why
21
+ * @property {string} [suggestion]
22
+ * @property {boolean} [naming]
23
+ */
24
+
25
+ /**
26
+ * Load every guard in the project.
27
+ *
28
+ * A missing guards folder is not a problem — plenty of projects start with
29
+ * pictures only and add guards the first time something comes back.
30
+ *
31
+ * @param {import('../types.js').Project} project
32
+ * @param {{only?: string}} [opts]
33
+ * @returns {Promise<import('../types.js').Guard[]>}
34
+ */
35
+ export async function loadGuards(project, opts = {}) {
36
+ const dir = project.paths.guards;
37
+ const files = (await collectFiles(dir)).sort();
38
+ if (files.length === 0) return [];
39
+
40
+ /** @type {import('../types.js').Guard[]} */
41
+ const guards = [];
42
+ /** @type {GuardProblem[]} */
43
+ const problems = [];
44
+
45
+ for (const file of files) {
46
+ const exported = await importGuards(file);
47
+ for (const raw of exported) {
48
+ const named = /** @type {any} */ (raw);
49
+ const label = typeof named.name === 'string' && named.name.trim() !== '' ? named.name : '(no name)';
50
+
51
+ const verdict = checkGuardName(named.name);
52
+ if (!verdict.ok) {
53
+ problems.push({
54
+ file,
55
+ label,
56
+ why: verdict.why ?? 'That name cannot be used.',
57
+ suggestion: verdict.suggestion,
58
+ naming: true,
59
+ });
60
+ continue;
61
+ }
62
+
63
+ if (typeof named.run !== 'function') {
64
+ problems.push({
65
+ file,
66
+ label,
67
+ why: 'This guard has no "run" function, so there is nothing for it to do. Give it `async run(app) { ... }`.',
68
+ });
69
+ continue;
70
+ }
71
+
72
+ guards.push(/** @type {import('../types.js').Guard} */ ({ ...named, file }));
73
+ }
74
+ }
75
+
76
+ // Two guards with the same name make a failure ambiguous: the report says the
77
+ // name, and nobody can tell which of the two actually broke.
78
+ /** @type {Map<string, string|undefined>} */
79
+ const seen = new Map();
80
+ /** @type {Set<string>} */
81
+ const reported = new Set();
82
+ for (const guard of guards) {
83
+ const first = seen.get(guard.name);
84
+ if (first === undefined) {
85
+ seen.set(guard.name, guard.file);
86
+ continue;
87
+ }
88
+ if (reported.has(guard.name)) continue;
89
+ reported.add(guard.name);
90
+ const where =
91
+ first === guard.file
92
+ ? 'Both are in this file.'
93
+ : `One is in ${shortPath(String(first))} and one is in ${shortPath(String(guard.file))}.`;
94
+ problems.push({
95
+ file: guard.file ?? first,
96
+ label: guard.name,
97
+ why: `Two guards share this name. ${where} When it fails nobody could tell which one broke, so give them different names.`,
98
+ });
99
+ }
100
+
101
+ if (problems.length > 0) throw problemsError(problems);
102
+
103
+ const only = typeof opts.only === 'string' ? opts.only.trim().toLowerCase() : '';
104
+ const chosen = only === '' ? guards : guards.filter((g) => g.name.toLowerCase().includes(only));
105
+
106
+ // A stable order, so two runs of the same project read the same way.
107
+ return chosen.sort((a, b) => {
108
+ const byFile = String(a.file).localeCompare(String(b.file));
109
+ return byFile !== 0 ? byFile : a.name.localeCompare(b.name);
110
+ });
111
+ }
112
+
113
+ /**
114
+ * Every `.js` / `.mjs` file under a folder, skipping `_`-prefixed and hidden
115
+ * entries (a handy way to park a guard) and anything inside node_modules.
116
+ *
117
+ * @param {string} dir
118
+ * @returns {Promise<string[]>}
119
+ */
120
+ async function collectFiles(dir) {
121
+ /** @type {string[]} */
122
+ const out = [];
123
+ let entries = /** @type {import('node:fs').Dirent[]} */ ([]);
124
+ try {
125
+ entries = await fsp.readdir(dir, { withFileTypes: true });
126
+ } catch {
127
+ return out;
128
+ }
129
+
130
+ for (const entry of entries) {
131
+ if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue;
132
+ if (entry.name === 'node_modules') continue;
133
+ const full = path.join(dir, entry.name);
134
+
135
+ let isDir = entry.isDirectory();
136
+ let isFile = entry.isFile();
137
+ if (entry.isSymbolicLink()) {
138
+ try {
139
+ const target = await fsp.stat(full);
140
+ isDir = target.isDirectory();
141
+ isFile = target.isFile();
142
+ } catch {
143
+ continue;
144
+ }
145
+ }
146
+
147
+ if (isDir) out.push(...(await collectFiles(full)));
148
+ else if (isFile && /\.(js|mjs)$/i.test(entry.name)) out.push(full);
149
+ }
150
+ return out;
151
+ }
152
+
153
+ /**
154
+ * Import one guard file and pull the guard objects out of it.
155
+ *
156
+ * @param {string} file
157
+ * @returns {Promise<unknown[]>}
158
+ */
159
+ async function importGuards(file) {
160
+ /** @type {any} */
161
+ let mod = undefined;
162
+ try {
163
+ // The modified time is part of the import url so a long-lived process — the
164
+ // MCP server, mostly — picks up an edited guard instead of a cached one,
165
+ // while an unchanged file still hits the module cache.
166
+ const stat = await fsp.stat(file);
167
+ mod = await import(`${pathToFileURL(file).href}?v=${Math.round(stat.mtimeMs)}`);
168
+ } catch (cause) {
169
+ throw new StaysFixedError(`The guard file ${shortPath(file)} could not be loaded: ${messageOfCause(cause)}`, {
170
+ cause,
171
+ hint: 'Guard files are plain JavaScript modules. Open the file and check it runs on its own — a typo or a bad import will stop the whole run.',
172
+ });
173
+ }
174
+
175
+ const fallback = mod?.default;
176
+ if (Array.isArray(fallback)) return fallback;
177
+ if (looksLikeGuard(fallback)) return [fallback];
178
+
179
+ /** @type {unknown[]} */
180
+ const found = [];
181
+ for (const [key, value] of Object.entries(mod ?? {})) {
182
+ if (key === 'default') continue;
183
+ if (Array.isArray(value)) {
184
+ for (const item of value) if (looksLikeGuard(item)) found.push(item);
185
+ } else if (looksLikeGuard(value)) {
186
+ found.push(value);
187
+ }
188
+ }
189
+
190
+ if (found.length === 0 && fallback !== undefined) {
191
+ throw new StaysFixedError(`The guard file ${shortPath(file)} does not export a guard.`, {
192
+ hint: 'Export the guard as the default: `export default { name: "the sidebar still collapses", async run(app) { ... } }`. An array of guards, or several named exports, work too.',
193
+ });
194
+ }
195
+
196
+ return found;
197
+ }
198
+
199
+ /**
200
+ * Loose enough to catch a half-written guard so we can explain what is missing,
201
+ * strict enough to ignore an exported constant that happens to sit alongside.
202
+ *
203
+ * @param {unknown} value
204
+ * @returns {boolean}
205
+ */
206
+ function looksLikeGuard(value) {
207
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
208
+ const obj = /** @type {Record<string, unknown>} */ (value);
209
+ return 'run' in obj || 'name' in obj;
210
+ }
211
+
212
+ /**
213
+ * @param {unknown} cause
214
+ * @returns {string}
215
+ */
216
+ function messageOfCause(cause) {
217
+ return cause instanceof Error ? cause.message : String(cause);
218
+ }
219
+
220
+ /**
221
+ * One error carrying every problem, so a person fixes them in a single pass.
222
+ *
223
+ * @param {GuardProblem[]} problems
224
+ * @returns {StaysFixedError}
225
+ */
226
+ function problemsError(problems) {
227
+ const count = problems.length;
228
+ const lines = [
229
+ `${count} guard${count === 1 ? '' : 's'} cannot run yet.`,
230
+ '',
231
+ ];
232
+
233
+ /** @type {Map<string, GuardProblem[]>} */
234
+ const byFile = new Map();
235
+ for (const p of problems) {
236
+ const list = byFile.get(p.file);
237
+ if (list) list.push(p);
238
+ else byFile.set(p.file, [p]);
239
+ }
240
+
241
+ for (const [file, list] of byFile) {
242
+ lines.push(` ${shortPath(file)}`);
243
+ for (const p of list) {
244
+ lines.push(` "${p.label}"`);
245
+ lines.push(` ${p.why}`);
246
+ if (p.suggestion) lines.push(` Try instead: "${p.suggestion}"`);
247
+ lines.push('');
248
+ }
249
+ }
250
+
251
+ const naming = problems.some((p) => p.naming);
252
+ return new StaysFixedError(lines.join('\n').trimEnd(), {
253
+ hint: naming ? NAME_RULE_EXPLAINER : undefined,
254
+ });
255
+ }
256
+
257
+ /**
258
+ * The starter guard file written by `staysfixed init`, and the example in the docs.
259
+ *
260
+ * It is deliberately a whole worked example rather than a stub: the first guard
261
+ * somebody writes sets the tone for every guard after it.
262
+ *
263
+ * @param {{name?: string, because?: string, fixed?: string}} [opts]
264
+ * @returns {string}
265
+ */
266
+ export function guardTemplate(opts = {}) {
267
+ const name = opts.name && opts.name.trim() !== '' ? opts.name : 'the sidebar still collapses';
268
+ const fixed = opts.fixed && opts.fixed.trim() !== '' ? opts.fixed : today();
269
+ const because =
270
+ opts.because && opts.because.trim() !== ''
271
+ ? opts.because
272
+ : 'Clicking the collapse arrow left the sidebar half open, so the main panel never got its width back. It came back twice after being fixed, which is why this guard exists.';
273
+
274
+ return `/**
275
+ * One guard per bug that has already been fixed. Its only job is to fail the day
276
+ * that bug comes back.
277
+ *
278
+ * The name is the whole point. Write what should still be true, in the words you
279
+ * would say out loud — six months from now that sentence is the only thing that
280
+ * will tell you what broke.
281
+ */
282
+
283
+ export default {
284
+ name: ${JSON.stringify(name)},
285
+
286
+ // When it was fixed. Handy when you are trying to remember the release.
287
+ fixed: ${JSON.stringify(fixed)},
288
+
289
+ // The story of the original bug. This gets printed when the guard fails, and
290
+ // it is usually the most useful line in the whole report.
291
+ because:
292
+ ${JSON.stringify(because)},
293
+
294
+ // link: 'https://github.com/you/your-app/issues/482',
295
+
296
+ async run({ open, click, expect, page }) {
297
+ await open('/');
298
+
299
+ // Put the app back in the state where the bug used to happen.
300
+ await click('[data-sf="sidebar-toggle"]');
301
+
302
+ // Then say, in plain words, what must still be true. If one of these turns
303
+ // out false, the failure reads as the sentence you wrote here.
304
+ await expect('the sidebar is hidden', async () => !(await page.visible('.sidebar')));
305
+
306
+ await expect('the main panel fills the window', async () => {
307
+ const box = await page.boxOf('.main');
308
+ return box !== null && box.width > 900;
309
+ });
310
+ },
311
+ };
312
+ `;
313
+ }
314
+
315
+ /**
316
+ * Today where the person is sitting, not in UTC — somebody running `init` late
317
+ * at night should not see yesterday's date in their first guard.
318
+ * @returns {string}
319
+ */
320
+ function today() {
321
+ const now = new Date();
322
+ const pad = (/** @type {number} */ n) => String(n).padStart(2, '0');
323
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
324
+ }