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,126 @@
1
+ /**
2
+ * `staysfixed approve` — the human gate.
3
+ *
4
+ * This is the one command an agent is not allowed to run for you. So it is also
5
+ * the one command that must never guess: with no name and no --all it lists what
6
+ * is waiting and stops. Approving is a person saying "yes, that is what I meant".
7
+ */
8
+
9
+ import { loadProject } from '../core/config.js';
10
+ import { approveScreens, projectStatus } from '../run.js';
11
+ import { say, ok, blank, heading, table, paint, mark } from '../core/log.js';
12
+ import { EXIT } from '../core/errors.js';
13
+
14
+ /**
15
+ * @param {import('./index.js').CliContext} ctx
16
+ * @returns {Promise<number>}
17
+ */
18
+ export async function run(ctx) {
19
+ const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
20
+ const all = ctx.bool('all');
21
+ const names = ctx.args.filter((a) => a.trim() !== '');
22
+
23
+ if (!all && names.length === 0) return listWhatIsWaiting(project);
24
+
25
+ const result = await approveScreens(
26
+ project,
27
+ names,
28
+ /** @type {any} */ ({ all, reason: ctx.str('reason'), tool: ctx.version }),
29
+ );
30
+
31
+ const approved = namesFrom(result, names);
32
+
33
+ blank();
34
+ if (approved.length === 0) {
35
+ say('Nothing was waiting, so nothing changed.');
36
+ say(paint.grey('Run `staysfixed check` first — approving only ever accepts a picture the last check took.'));
37
+ blank();
38
+ return EXIT.ok;
39
+ }
40
+
41
+ const one = approved.length === 1;
42
+ ok(`${one ? 'This picture is' : 'These pictures are'} the new normal now:`);
43
+ for (const name of approved) say(` ${paint.green(mark.pass)} ${name}`);
44
+ const reason = ctx.str('reason');
45
+ if (reason) say(paint.grey(` reason saved with ${one ? 'it' : 'them'}: ${reason}`));
46
+ blank();
47
+ say(paint.grey(`From now on every check measures against ${one ? 'it' : 'them'}. Commit ${one ? 'it' : 'them'} with your code.`));
48
+ blank();
49
+ return EXIT.ok;
50
+ }
51
+
52
+ /**
53
+ * With nothing named, say what could be approved and how — never approve.
54
+ * @param {import('../types.js').Project} project
55
+ * @returns {Promise<number>}
56
+ */
57
+ async function listWhatIsWaiting(project) {
58
+ const status = /** @type {any} */ (await projectStatus(project));
59
+ /** @type {import('../types.js').PictureResult[]} */
60
+ const pictures = status?.lastRun?.pictures ?? [];
61
+ const waiting = pictures.filter((p) => p.status === 'changed' || p.status === 'new' || p.status === 'missing');
62
+
63
+ if (!status?.lastRun) {
64
+ blank();
65
+ say('Nothing has been checked here yet, so there is nothing to approve.');
66
+ say(`Run ${paint.cyan('staysfixed check')} first.`);
67
+ blank();
68
+ return EXIT.ok;
69
+ }
70
+ if (waiting.length === 0) {
71
+ blank();
72
+ ok('Nothing is waiting for you. Every picture already matches the approved one.');
73
+ blank();
74
+ return EXIT.ok;
75
+ }
76
+
77
+ heading('Waiting for you to look at');
78
+ table(
79
+ waiting.map((p) => [p.name, paint.grey(whatHappened(p))]),
80
+ { indent: 2 },
81
+ );
82
+ blank();
83
+ say('Look at each one, then accept the ones that are right:');
84
+ for (const p of waiting.slice(0, 10)) say(` ${paint.cyan(`staysfixed approve ${p.name}`)}`);
85
+ if (waiting.length > 10) say(paint.grey(` ...and ${waiting.length - 10} more`));
86
+ if (waiting.length > 1) say(`Or accept every one of them: ${paint.cyan('staysfixed approve --all')}`);
87
+ blank();
88
+ say(paint.grey('Nothing was approved just now. Approving is deliberate, on purpose.'));
89
+ blank();
90
+ return EXIT.ok;
91
+ }
92
+
93
+ /**
94
+ * @param {import('../types.js').PictureResult} p
95
+ * @returns {string}
96
+ */
97
+ function whatHappened(p) {
98
+ if (p.status === 'new') return 'brand new — there is no approved picture yet';
99
+ if (p.status === 'missing') return 'the approved picture is gone';
100
+ return 'it looks different from the approved picture';
101
+ }
102
+
103
+ /**
104
+ * The runner may hand back names, picture results, or a small report. Read all
105
+ * three, so this command never has to guess which one it got.
106
+ *
107
+ * @param {unknown} result
108
+ * @param {string[]} asked
109
+ * @returns {string[]}
110
+ */
111
+ function namesFrom(result, asked) {
112
+ /** @type {any} */
113
+ const value = result;
114
+ /** @type {unknown[]} */
115
+ let list = [];
116
+ if (Array.isArray(value)) list = value;
117
+ else if (Array.isArray(value?.approved)) list = value.approved;
118
+ else if (Array.isArray(value?.names)) list = value.names;
119
+ else if (Array.isArray(value?.pictures)) list = value.pictures;
120
+ else if (value === undefined || value === null) return asked;
121
+
122
+ const names = list
123
+ .map((item) => (typeof item === 'string' ? item : /** @type {any} */ (item)?.name))
124
+ .filter((name) => typeof name === 'string' && name !== '');
125
+ return /** @type {string[]} */ (names);
126
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * `staysfixed check` — the one command people actually run.
3
+ *
4
+ * Results are printed the moment each one lands, because a run that goes quiet
5
+ * for two minutes feels broken. The summary at the end is the part that matters.
6
+ */
7
+
8
+ import { loadProject } from '../core/config.js';
9
+ import { runCheck } from '../run.js';
10
+ import { printPictureResult, printGuardResult, printRunSummary } from '../report/console.js';
11
+ import { setLogLevel } from '../core/log.js';
12
+ import { EXIT } from '../core/errors.js';
13
+
14
+ /**
15
+ * @param {import('./index.js').CliContext} ctx
16
+ * @returns {Promise<number>}
17
+ */
18
+ export async function run(ctx) {
19
+ const asJson = ctx.bool('json');
20
+ // With --json the only thing on stdout may be the JSON itself.
21
+ if (asJson) setLogLevel({ quiet: true, verbose: false });
22
+
23
+ const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
24
+
25
+ const onlyGuards = ctx.bool('guards');
26
+ const onlyPictures = ctx.bool('pictures');
27
+
28
+ /** Printed once, whether it arrived live or only in the summary. */
29
+ const shown = new Set();
30
+
31
+ /** @param {import('../types.js').PictureResult} result */
32
+ const showPicture = (result) => {
33
+ const key = `picture:${result.name}`;
34
+ if (shown.has(key)) return;
35
+ shown.add(key);
36
+ if (!asJson) printPictureResult(result);
37
+ };
38
+
39
+ /** @param {import('../types.js').GuardResult} result */
40
+ const showGuard = (result) => {
41
+ const key = `guard:${result.name}`;
42
+ if (shown.has(key)) return;
43
+ shown.add(key);
44
+ if (!asJson) printGuardResult(result);
45
+ };
46
+
47
+ const opts = /** @type {any} */ ({
48
+ only: ctx.list('only'),
49
+ // These names are the contract runCheck reads. They were `pictures` and `guards`
50
+ // here once, which typechecked fine and silently did nothing: `--guards` still
51
+ // photographed all thirteen screens.
52
+ guardsOnly: onlyGuards && !onlyPictures,
53
+ picturesOnly: onlyPictures && !onlyGuards,
54
+ record: ctx.bool('record'),
55
+ writeReport: ctx.flags.report !== false && !asJson,
56
+ onPicture: showPicture,
57
+ onGuard: showGuard,
58
+ tool: ctx.version,
59
+ });
60
+
61
+ /** @type {import('../types.js').RunSummary} */
62
+ const summary = await runCheck(project, opts);
63
+
64
+ if (asJson) {
65
+ process.stdout.write(JSON.stringify(summary, null, 2) + '\n');
66
+ } else {
67
+ for (const picture of summary.pictures ?? []) showPicture(picture);
68
+ for (const guard of summary.guards ?? []) showGuard(guard);
69
+ printRunSummary(summary, project);
70
+ }
71
+
72
+ return summary.ok ? EXIT.ok : EXIT.failed;
73
+ }
@@ -0,0 +1,379 @@
1
+ /**
2
+ * `staysfixed doctor` — the "why will this not work" command.
3
+ *
4
+ * Every other command is allowed to give up on the first problem. This one is
5
+ * not: somebody running doctor is already stuck, and being told about one broken
6
+ * thing at a time is how people give up on a tool. So it collects everything and
7
+ * never throws.
8
+ */
9
+
10
+ import path from 'node:path';
11
+ import fsp from 'node:fs/promises';
12
+ import { loadProject } from '../core/config.js';
13
+ import { findConfigFile, GITIGNORE_LINES } from '../core/paths.js';
14
+ import { findChrome, resolveElectronBinary, platformTag } from '../drive/find.js';
15
+ import { listElectronWindows } from '../drive/electron.js';
16
+ import { loadGuards } from '../guard/load.js';
17
+ import { isRepo } from '../core/git.js';
18
+ import { messageOf, isExpected, EXIT } from '../core/errors.js';
19
+ import { say, ok, warn, fail, blank, heading, paint, mark, shortPath } from '../core/log.js';
20
+
21
+ /**
22
+ * @typedef {object} Note
23
+ * @property {'ok'|'warn'|'bad'} level
24
+ * @property {string} text
25
+ * @property {string[]} [more]
26
+ */
27
+
28
+ /**
29
+ * @param {import('./index.js').CliContext} ctx
30
+ * @returns {Promise<number>}
31
+ */
32
+ export async function run(ctx) {
33
+ /** @type {Note[]} */
34
+ const notes = [];
35
+ const good = (/** @type {string} */ text, /** @type {string[]} */ more = []) => notes.push({ level: 'ok', text, more });
36
+ const soso = (/** @type {string} */ text, /** @type {string[]} */ more = []) => notes.push({ level: 'warn', text, more });
37
+ const bad = (/** @type {string} */ text, /** @type {string[]} */ more = []) => notes.push({ level: 'bad', text, more });
38
+ const fix = ctx.bool('fix');
39
+
40
+ heading('Stays Fixed — checking this project');
41
+ blank();
42
+
43
+ // 1. Settings.
44
+ const configFile = ctx.configFile ? path.resolve(ctx.cwd, ctx.configFile) : findConfigFile(ctx.cwd);
45
+ /** @type {import('../types.js').Project|null} */
46
+ let project = null;
47
+
48
+ if (!configFile) {
49
+ bad('There are no settings here — Stays Fixed does not know what to open.', [
50
+ 'Run `staysfixed init` in this folder and it will write them for you.',
51
+ ]);
52
+ } else {
53
+ good(`Settings found: ${shortPath(configFile)}`);
54
+ try {
55
+ project = await loadProject({ cwd: ctx.cwd, configFile });
56
+ const screens = project.config.screens.length;
57
+ good(`The settings read fine — ${screens} ${screens === 1 ? 'screen' : 'screens'} named in them.`);
58
+ if (screens === 0) {
59
+ soso('No screens are listed, so `staysfixed check` has no pictures to take.', [
60
+ 'Add one under `screens:` in your settings file.',
61
+ ]);
62
+ }
63
+ } catch (error) {
64
+ bad(messageOf(error), hintsOf(error));
65
+ }
66
+ }
67
+
68
+ const root = project ? project.paths.root : ctx.cwd;
69
+
70
+ // 2. The app itself.
71
+ if (project) {
72
+ const app = project.config.app;
73
+ if (app.attach) {
74
+ await checkAttached(app.attach, good, soso);
75
+ } else if (app.kind === 'web') {
76
+ await checkWebApp(app, good, soso, bad);
77
+ } else {
78
+ checkElectronApp(app, good, bad);
79
+ }
80
+
81
+ // 3. A browser to drive it with. Electron brings its own.
82
+ if (app.kind === 'web' && !app.attach) {
83
+ const browser = findChrome(app.browser ?? process.env.STAYSFIXED_CHROME);
84
+ if (browser) good(`Browser it will use: ${browser}`);
85
+ else
86
+ bad('No Chrome, Chromium, Brave or Edge could be found on this machine.', [
87
+ 'Install Google Chrome, or point Stays Fixed at the one you have:',
88
+ "set `app: { browser: '/path/to/chrome' }` in your settings, or the STAYSFIXED_CHROME environment variable.",
89
+ ]);
90
+ }
91
+ }
92
+
93
+ // 4. The folders, and 5. the pictures inside them.
94
+ if (project) {
95
+ const wanted = [
96
+ ['the state folder', project.paths.dir],
97
+ ['approved pictures', project.paths.approved],
98
+ ['guards', project.paths.guards],
99
+ ['markers', project.paths.markers],
100
+ ];
101
+ /** @type {string[]} */
102
+ const missing = [];
103
+ for (const [label, dir] of wanted) {
104
+ if (!(await isDir(dir))) missing.push(`${label} (${shortPath(dir)})`);
105
+ }
106
+ if (missing.length === 0) {
107
+ good('All the folders it needs are there.');
108
+ } else if (fix) {
109
+ for (const [, dir] of wanted) await fsp.mkdir(dir, { recursive: true }).catch(() => {});
110
+ good(`Made the folders that were missing: ${missing.length}.`);
111
+ } else {
112
+ soso(`${missing.length} ${missing.length === 1 ? 'folder is' : 'folders are'} missing.`, [
113
+ ...missing.map((m) => `${mark.info} ${m}`),
114
+ 'Run `staysfixed doctor --fix` to make them.',
115
+ ]);
116
+ }
117
+
118
+ const pictures = await approvedPictures(project.paths.approved);
119
+ if (pictures.length === 0) {
120
+ soso('There are no approved pictures yet, so there is nothing to compare against.', [
121
+ 'Run `staysfixed check`, look at what it took, then `staysfixed approve --all`.',
122
+ ]);
123
+ } else {
124
+ good(`${pictures.length} approved ${pictures.length === 1 ? 'picture' : 'pictures'} on disk.`);
125
+ }
126
+
127
+ // 9. Text is drawn differently on every operating system, so a picture taken
128
+ // on a Mac will never match one taken on Linux. Worth saying out loud.
129
+ const here = platformTag();
130
+ const elsewhere = [...new Set(pictures.map((p) => p.platform).filter((p) => typeof p === 'string' && p !== here))];
131
+ if (elsewhere.length > 0) {
132
+ soso(`Some approved pictures were taken on a different system (${elsewhere.join(', ')}); this one is ${here}.`, [
133
+ 'Text is drawn differently on every operating system, so those will look changed here even when nothing is wrong.',
134
+ 'Approve them again on this machine, or take the pictures on one machine only — usually CI.',
135
+ ]);
136
+ } else if (pictures.length > 0) {
137
+ good(`Every approved picture was taken on this kind of machine (${here}).`);
138
+ }
139
+ }
140
+
141
+ // 6. Guards.
142
+ if (project) {
143
+ try {
144
+ const guards = await loadGuards(project);
145
+ if (guards.length === 0) {
146
+ soso('No guards yet.', [
147
+ 'A guard is one check per bug you have already fixed, named in plain English.',
148
+ 'The next time something comes back, write one — that is the whole point of them.',
149
+ ]);
150
+ } else {
151
+ good(`${guards.length} ${guards.length === 1 ? 'guard loads' : 'guards load'}, and every name reads like a sentence.`);
152
+ }
153
+ } catch (error) {
154
+ bad('The guards could not be loaded.', [...messageOf(error).split('\n'), ...hintsOf(error)]);
155
+ }
156
+ }
157
+
158
+ // 7. .gitignore.
159
+ await checkGitignore(root, fix, good, soso);
160
+
161
+ // 8. git.
162
+ if (await isRepo(root)) good('This is a git repository, so a regression can be traced to a commit.');
163
+ else
164
+ soso('This folder is not a git repository.', [
165
+ 'Everything still works, but `staysfixed trace` cannot tell you which commit changed something.',
166
+ ]);
167
+
168
+ // A last word about the rule that matters most.
169
+ if (project?.config.mcp.allowApprove) {
170
+ soso('Your settings let an agent approve its own pictures (`mcp.allowApprove: true`).', [
171
+ 'That gives away the only thing this tool really guarantees. Turn it off unless you meant it.',
172
+ ]);
173
+ }
174
+
175
+ return report(notes);
176
+ }
177
+
178
+ /**
179
+ * The advice an expected error carries, as lines. Anything else has none.
180
+ * @param {unknown} error
181
+ * @returns {string[]}
182
+ */
183
+ function hintsOf(error) {
184
+ if (!isExpected(error) || !error.hint) return [];
185
+ return error.hint.split('\n');
186
+ }
187
+
188
+ /**
189
+ * @param {Note[]} notes
190
+ * @returns {number}
191
+ */
192
+ function report(notes) {
193
+ for (const note of notes) {
194
+ if (note.level === 'ok') ok(note.text);
195
+ else if (note.level === 'warn') warn(note.text);
196
+ else fail(note.text);
197
+ for (const line of note.more ?? []) say(paint.grey(` ${line}`));
198
+ }
199
+
200
+ const bad = notes.filter((n) => n.level === 'bad').length;
201
+ const warns = notes.filter((n) => n.level === 'warn').length;
202
+
203
+ blank();
204
+ if (bad > 0) {
205
+ fail(`${bad} ${bad === 1 ? 'thing has' : 'things have'} to be fixed before Stays Fixed can run here.`);
206
+ blank();
207
+ return EXIT.failed;
208
+ }
209
+ if (warns > 0) {
210
+ warn(`It will run. ${warns} ${warns === 1 ? 'thing is' : 'things are'} worth a look.`);
211
+ blank();
212
+ return EXIT.ok;
213
+ }
214
+ ok('Everything it needs is in place.');
215
+ blank();
216
+ return EXIT.ok;
217
+ }
218
+
219
+ /**
220
+ * @param {import('../types.js').AppConfig} app
221
+ * @param {(t: string, more?: string[]) => void} good
222
+ * @param {(t: string, more?: string[]) => void} soso
223
+ * @param {(t: string, more?: string[]) => void} bad
224
+ */
225
+ async function checkWebApp(app, good, soso, bad) {
226
+ const url = app.url;
227
+ if (!url) {
228
+ bad('The settings do not say which address to open.');
229
+ return;
230
+ }
231
+ const answered = await answers(url);
232
+ if (answered.ok) {
233
+ good(`Your app answered at ${url}.`);
234
+ return;
235
+ }
236
+ if (app.start) {
237
+ soso(`Nothing is answering at ${url} right now.`, [
238
+ `That is fine — Stays Fixed will start it itself with: ${app.start}`,
239
+ `It could not reach it just now because: ${answered.why}`,
240
+ ]);
241
+ return;
242
+ }
243
+ bad(`Nothing is answering at ${url}.`, [
244
+ `Tried it and got: ${answered.why}`,
245
+ 'Start your app first, or add `app: { start: "npm run dev" }` to your settings so Stays Fixed starts it for you.',
246
+ ]);
247
+ }
248
+
249
+ /**
250
+ * @param {import('../types.js').AppConfig} app
251
+ * @param {(t: string, more?: string[]) => void} good
252
+ * @param {(t: string, more?: string[]) => void} bad
253
+ */
254
+ function checkElectronApp(app, good, bad) {
255
+ try {
256
+ const binary = resolveElectronBinary(app.binary ?? '');
257
+ good(`The app it will open: ${binary}`);
258
+ } catch (error) {
259
+ bad(messageOf(error), hintsOf(error));
260
+ }
261
+ }
262
+
263
+ /**
264
+ * @param {string} endpoint
265
+ * @param {(t: string, more?: string[]) => void} good
266
+ * @param {(t: string, more?: string[]) => void} soso
267
+ */
268
+ async function checkAttached(endpoint, good, soso) {
269
+ try {
270
+ const windows = await listElectronWindows(endpoint);
271
+ const pages = windows.filter((w) => w.type === 'page');
272
+ good(`Attached to what is already running at ${endpoint} — ${pages.length} ${pages.length === 1 ? 'window' : 'windows'} open.`);
273
+ for (const window of pages.slice(0, 6)) {
274
+ say(paint.grey(` ${window.title || '(no title)'} ${paint.grey(window.url)}`));
275
+ }
276
+ if (pages.length > 1) {
277
+ say(paint.grey(' More than one window is open. Put a word from the right title in `app.windowMatch`.'));
278
+ }
279
+ } catch (error) {
280
+ soso(`Nothing is listening at ${endpoint} right now.`, [
281
+ `Tried it and got: ${messageOf(error)}`,
282
+ 'Start the app with remote debugging on before running a check, or drop `app.attach` and let Stays Fixed launch it.',
283
+ ]);
284
+ }
285
+ }
286
+
287
+ /**
288
+ * @param {string} root
289
+ * @param {boolean} fix
290
+ * @param {(t: string, more?: string[]) => void} good
291
+ * @param {(t: string, more?: string[]) => void} soso
292
+ */
293
+ async function checkGitignore(root, fix, good, soso) {
294
+ const file = path.join(root, '.gitignore');
295
+ let current = '';
296
+ try {
297
+ current = await fsp.readFile(file, 'utf8');
298
+ } catch {
299
+ current = '';
300
+ }
301
+ const lines = new Set(current.split('\n').map((l) => l.trim()));
302
+ const missing = GITIGNORE_LINES.filter((line) => !line.startsWith('#') && !lines.has(line.trim()));
303
+
304
+ if (missing.length === 0) {
305
+ good('Your .gitignore already keeps the throwaway files out of git.');
306
+ return;
307
+ }
308
+ if (fix) {
309
+ const prefix = current === '' || current.endsWith('\n') ? '' : '\n';
310
+ await fsp.writeFile(file, `${current}${prefix}\n${GITIGNORE_LINES.join('\n')}\n`);
311
+ good(`Added ${missing.length} ${missing.length === 1 ? 'line' : 'lines'} to .gitignore.`);
312
+ return;
313
+ }
314
+ soso('Your .gitignore is missing the lines that keep throwaway files out of git.', [
315
+ ...missing.map((line) => `${mark.info} ${line}`),
316
+ 'Run `staysfixed doctor --fix` to add them.',
317
+ ]);
318
+ }
319
+
320
+ /**
321
+ * @param {string} url
322
+ * @returns {Promise<{ok: boolean, why: string}>}
323
+ */
324
+ async function answers(url) {
325
+ try {
326
+ const response = await fetch(url, { signal: AbortSignal.timeout(4000), redirect: 'manual' });
327
+ return { ok: response.status < 500, why: `${response.status}` };
328
+ } catch (error) {
329
+ return { ok: false, why: messageOf(error) };
330
+ }
331
+ }
332
+
333
+ /**
334
+ * @param {string} dir
335
+ * @returns {Promise<boolean>}
336
+ */
337
+ async function isDir(dir) {
338
+ try {
339
+ return (await fsp.stat(dir)).isDirectory();
340
+ } catch {
341
+ return false;
342
+ }
343
+ }
344
+
345
+ /**
346
+ * The metadata next to every approved picture, so doctor can spot pictures that
347
+ * were taken somewhere else.
348
+ * @param {string} dir
349
+ * @returns {Promise<import('../types.js').PictureMeta[]>}
350
+ */
351
+ async function approvedPictures(dir) {
352
+ /** @type {import('../types.js').PictureMeta[]} */
353
+ const out = [];
354
+ /** @type {string[]} */
355
+ let names = [];
356
+ try {
357
+ names = await fsp.readdir(dir);
358
+ } catch {
359
+ return out;
360
+ }
361
+ for (const name of names) {
362
+ if (!name.endsWith('.png')) continue;
363
+ const meta = await readMeta(path.join(dir, name.replace(/\.png$/, '.json')));
364
+ out.push(meta ?? /** @type {any} */ ({ name: name.replace(/\.png$/, '') }));
365
+ }
366
+ return out;
367
+ }
368
+
369
+ /**
370
+ * @param {string} file
371
+ * @returns {Promise<import('../types.js').PictureMeta|null>}
372
+ */
373
+ async function readMeta(file) {
374
+ try {
375
+ return JSON.parse(await fsp.readFile(file, 'utf8'));
376
+ } catch {
377
+ return null;
378
+ }
379
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `staysfixed flake` — the register of checks that cannot make up their mind.
3
+ *
4
+ * A check that cries wolf gets ignored, and then the real one gets ignored too.
5
+ * So this command exists to make wobbling visible rather than tolerable.
6
+ */
7
+
8
+ import { loadProject } from '../core/config.js';
9
+ import { loadHistory, saveHistory, clearFlakes, condemned, blindWithoutGit } from '../core/history.js';
10
+ import { printFlakes } from '../report/console.js';
11
+ import { say, ok, warn, blank, paint } from '../core/log.js';
12
+ import { gitInfo } from '../core/git.js';
13
+ import { EXIT } from '../core/errors.js';
14
+
15
+ /**
16
+ * @param {import('./index.js').CliContext} ctx
17
+ * @returns {Promise<number>}
18
+ */
19
+ export async function run(ctx) {
20
+ const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
21
+ const history = await loadHistory(project.paths.historyFile);
22
+
23
+ const forgive = ctx.str('clear');
24
+ if (forgive) {
25
+ const known = Object.values(history).some((entry) => entry.name === forgive);
26
+ if (!known) {
27
+ blank();
28
+ warn(`No check called "${forgive}" has ever wobbled, so there is nothing to forgive.`);
29
+ blank();
30
+ return EXIT.failed;
31
+ }
32
+ await saveHistory(project.paths.historyFile, clearFlakes(history, forgive));
33
+ blank();
34
+ ok(`"${forgive}" starts again with a clean record.`);
35
+ say(paint.grey('If it wobbles again, it is not fixed.'));
36
+ blank();
37
+ return EXIT.ok;
38
+ }
39
+
40
+ if (ctx.bool('json')) {
41
+ process.stdout.write(JSON.stringify(history, null, 2) + '\n');
42
+ return EXIT.ok;
43
+ }
44
+
45
+ printFlakes(history, project.config.flakeLimit);
46
+
47
+ // A register that looks clean for the wrong reason is worse than one that admits what
48
+ // it cannot see. Comparing a check's verdict between two runs only means something if
49
+ // the code stood still in between, and only git can say that.
50
+ if (blindWithoutGit(await gitInfo(project.paths.root))) {
51
+ say(
52
+ paint.grey(
53
+ 'Note: this folder has no commit to pin results to, so a wobble is only counted when a\n' +
54
+ 'check needs a second try inside one run. Commit your work and the register sees more.',
55
+ ),
56
+ );
57
+ blank();
58
+ }
59
+
60
+ return condemned(history).length > 0 ? EXIT.failed : EXIT.ok;
61
+ }