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,327 @@
1
+ /**
2
+ * The naming rule for guards, enforced instead of hoped for.
3
+ *
4
+ * A guard outlives the memory of the bug it was written for. The name is the
5
+ * whole handover: it is what fails in CI, what an agent reads back, and what a
6
+ * person has to judge in five seconds at midnight. So the tool refuses names
7
+ * that only make sense to whoever typed them.
8
+ */
9
+
10
+ /**
11
+ * @typedef {object} NameVerdict
12
+ * @property {boolean} ok
13
+ * @property {string} [why] Plain-language reason it was refused.
14
+ * @property {string} [suggestion] A rewrite, only when we can honestly offer one.
15
+ */
16
+
17
+ /** Why the rule exists. Printed by the CLI whenever a name is refused. */
18
+ export const NAME_RULE_EXPLAINER =
19
+ 'A guard name is the only thing that still makes sense six months from now. ' +
20
+ 'It is what gets printed when the guard fails, what goes in the report, and what an agent reads ' +
21
+ 'before deciding whether it broke something. So write what the app is supposed to do, in the words ' +
22
+ 'you would say out loud: "the sidebar still collapses", "prices still show two decimals", ' +
23
+ '"logging out clears the session". A name like sidebar_collapse_test tells the next person nothing ' +
24
+ 'about what broke or whether it matters. Three plain words minimum, present tense, no test ids.';
25
+
26
+ const MAX_LENGTH = 120;
27
+
28
+ /** Words that start a test name rather than describe the app. */
29
+ const TEST_SPEAK = new Set([
30
+ 'test',
31
+ 'tests',
32
+ 'it',
33
+ 'should',
34
+ 'shall',
35
+ 'verify',
36
+ 'verifies',
37
+ 'check',
38
+ 'checks',
39
+ 'assert',
40
+ 'asserts',
41
+ 'ensure',
42
+ 'ensures',
43
+ ]);
44
+
45
+ /** Words that add nothing once the name is a sentence. */
46
+ const NOISE = new Set([
47
+ 'test',
48
+ 'tests',
49
+ 'testing',
50
+ 'spec',
51
+ 'specs',
52
+ 'should',
53
+ 'shall',
54
+ 'it',
55
+ 'verify',
56
+ 'verifies',
57
+ 'verified',
58
+ 'check',
59
+ 'checks',
60
+ 'checked',
61
+ 'assert',
62
+ 'asserts',
63
+ 'ensure',
64
+ 'ensures',
65
+ 'case',
66
+ 'cases',
67
+ 'regression',
68
+ 'regressions',
69
+ 'bug',
70
+ 'bugs',
71
+ 'guard',
72
+ 'guards',
73
+ 'e2e',
74
+ 'unit',
75
+ 'snapshot',
76
+ 'fix',
77
+ 'fixed',
78
+ 'fixes',
79
+ 'still',
80
+ 'again',
81
+ ]);
82
+
83
+ const ARTICLES = new Set(['the', 'a', 'an']);
84
+
85
+ /**
86
+ * Verbs a user interface actually does. The rewriter only offers a sentence when
87
+ * it can find one of these, because guessing a verb out of a noun produces
88
+ * confident nonsense ("the login form still validations") and people accept
89
+ * suggestions without reading them.
90
+ */
91
+ const VERBS = new Set([
92
+ 'align', 'appear', 'apply', 'build', 'cancel', 'clear', 'close', 'collapse', 'connect', 'copy',
93
+ 'delete', 'disappear', 'disconnect', 'download', 'drag', 'drop', 'exit', 'expand', 'export',
94
+ 'fit', 'filter', 'focus', 'format', 'highlight', 'hide', 'hold', 'import', 'install', 'keep',
95
+ 'launch', 'load', 'log', 'match', 'mount', 'navigate', 'open', 'paginate', 'parse', 'paste',
96
+ 'persist', 'print', 'reconnect', 'redirect', 'redo', 'refresh', 'remain', 'render', 'reset',
97
+ 'resize', 'restore', 'resume', 'retry', 'return', 'run', 'save', 'scroll', 'search', 'select',
98
+ 'show', 'sign', 'sort', 'start', 'stay', 'stop', 'submit', 'sync', 'toggle', 'undo', 'update',
99
+ 'upload', 'validate', 'work', 'wrap',
100
+ ]);
101
+
102
+ /**
103
+ * @typedef {'empty'|'long'|'path'|'id'|'symbols'|'caps'|'identifier'|'testspeak'|'short'} RefusalKind
104
+ */
105
+
106
+ /**
107
+ * Is this name acceptable, and if not, why — and can we offer a rewrite?
108
+ *
109
+ * @param {unknown} name
110
+ * @returns {NameVerdict}
111
+ */
112
+ export function checkGuardName(name) {
113
+ const refusal = refuse(name);
114
+ if (!refusal) return { ok: true };
115
+ const suggestion = suggestFor(typeof name === 'string' ? name : '', refusal.kind);
116
+ return suggestion ? { ok: false, why: refusal.why, suggestion } : { ok: false, why: refusal.why };
117
+ }
118
+
119
+ /**
120
+ * The rule itself, with no rewriting. Kept separate so a suggested rewrite can be
121
+ * run back through it without ever recursing into the suggester.
122
+ *
123
+ * @param {unknown} name
124
+ * @returns {{kind: RefusalKind, why: string}|null}
125
+ */
126
+ function refuse(name) {
127
+ if (typeof name !== 'string' || name.trim() === '') {
128
+ return {
129
+ kind: 'empty',
130
+ why: 'A guard needs a name. Give it one plain sentence saying what should still be true, like "the sidebar still collapses".',
131
+ };
132
+ }
133
+
134
+ const text = name.trim();
135
+
136
+ if (text.length > MAX_LENGTH) {
137
+ return {
138
+ kind: 'long',
139
+ why: `That name is ${text.length} characters long. Keep it under ${MAX_LENGTH} — a guard name is a short sentence, not a paragraph. Put the story of the bug in "because" instead.`,
140
+ };
141
+ }
142
+
143
+ if (/[\\/]/.test(text) || /\.(js|mjs|cjs|ts|tsx|jsx|json|py|rb|go|rs)$/i.test(text)) {
144
+ return {
145
+ kind: 'path',
146
+ why: 'That looks like a file name, not a description. Say what the app should still do, not where the code that does it lives.',
147
+ };
148
+ }
149
+
150
+ if (/^#?[A-Za-z]{0,8}[-_ #]?\d+$/.test(text)) {
151
+ return {
152
+ kind: 'id',
153
+ why: 'That is an issue number, not a description. The number will not tell anyone what broke — put it in "link" and use the name to say what should still work.',
154
+ };
155
+ }
156
+
157
+ if (text.includes('#') || text.includes('::')) {
158
+ return {
159
+ kind: 'symbols',
160
+ why: 'Names containing "#" or "::" read like code references. Write the behaviour in ordinary words, and put any issue or commit reference in "link".',
161
+ };
162
+ }
163
+
164
+ if (/[A-Za-z]/.test(text) && text === text.toUpperCase()) {
165
+ return {
166
+ kind: 'caps',
167
+ why: 'ALL CAPS reads like shouting, not like a sentence. Write it the way you would say it out loud.',
168
+ };
169
+ }
170
+
171
+ const words = text.split(/\s+/).filter(Boolean);
172
+
173
+ // One token that carries word boundaries inside it — snake_case, kebab-case,
174
+ // SCREAMING_CASE or camelCase. These are identifiers, not sentences.
175
+ if (words.length === 1 && (/[_.-]/.test(text) || /[a-z][A-Z]/.test(text))) {
176
+ return {
177
+ kind: 'identifier',
178
+ why: 'That reads like a code identifier, not a sentence. Guard names are printed to people, so use spaces and ordinary words.',
179
+ };
180
+ }
181
+
182
+ const first = words[0].toLowerCase().replace(/[^a-z]/g, '');
183
+ if (words.length > 1 && TEST_SPEAK.has(first)) {
184
+ return {
185
+ kind: 'testspeak',
186
+ why: `Starting with "${words[0]}" describes a test, not the app. Drop the test word and say what should still be true.`,
187
+ };
188
+ }
189
+
190
+ if (words.length < 3) {
191
+ return {
192
+ kind: 'short',
193
+ why: `That is only ${words.length} word${words.length === 1 ? '' : 's'}. Use at least three, so the name says what should still be true and not just which area it touches.`,
194
+ };
195
+ }
196
+
197
+ return null;
198
+ }
199
+
200
+ /**
201
+ * Offer a rewrite, but only one we would stand behind. Anything we cannot turn
202
+ * into a real sentence gets no suggestion at all — a wrong suggestion is worse
203
+ * than none, because people accept them.
204
+ *
205
+ * @param {string} name
206
+ * @param {RefusalKind} kind
207
+ * @returns {string|undefined}
208
+ */
209
+ function suggestFor(name, kind) {
210
+ // An issue number carries no behaviour at all, so there is nothing to rewrite from.
211
+ if (kind === 'empty' || kind === 'long' || kind === 'id') return undefined;
212
+
213
+ // Lower-casing keeps the author's own grammar, which beats anything we build.
214
+ if (kind === 'caps') {
215
+ const lowered = name.trim().toLowerCase();
216
+ if (!refuse(lowered)) return lowered;
217
+ }
218
+
219
+ const built = sentenceFrom(name);
220
+ if (built) return built;
221
+
222
+ if (kind === 'testspeak') {
223
+ const rest = name.trim().split(/\s+/).slice(1).join(' ');
224
+ if (rest && !refuse(rest)) return rest;
225
+ }
226
+
227
+ if (kind === 'symbols') {
228
+ const stripped = name
229
+ .replace(/::/g, ' ')
230
+ .replace(/#\s*\d*/g, ' ')
231
+ .replace(/\s+/g, ' ')
232
+ .trim();
233
+ if (stripped && !refuse(stripped)) return stripped;
234
+ }
235
+
236
+ return undefined;
237
+ }
238
+
239
+ /**
240
+ * Turn `sidebar_collapse_test` into "the sidebar still collapses".
241
+ *
242
+ * Only attempted when a real verb can be found, and only when something is left
243
+ * in front of it to be the subject.
244
+ *
245
+ * @param {string} raw
246
+ * @returns {string|undefined}
247
+ */
248
+ function sentenceFrom(raw) {
249
+ let base = String(raw).trim();
250
+ base = base.split(/[\\/]/).pop() ?? base;
251
+ base = base.replace(/\.(js|mjs|cjs|ts|tsx|jsx|json|py|rb|go|rs)$/i, '');
252
+
253
+ let words = splitWords(base).filter((w) => !NOISE.has(w));
254
+ while (words.length > 0 && ARTICLES.has(words[0])) words = words.slice(1);
255
+ if (words.length < 2) return undefined;
256
+
257
+ let at = -1;
258
+ let stem = '';
259
+ for (let i = 1; i < words.length; i += 1) {
260
+ const found = verbStem(words[i]);
261
+ if (found) {
262
+ at = i;
263
+ stem = found;
264
+ break;
265
+ }
266
+ }
267
+ if (at < 1) return undefined;
268
+
269
+ const subject = words.slice(0, at);
270
+ const tail = words.slice(at + 1);
271
+ // "the prices still show" — a plural subject takes the bare verb.
272
+ const last = subject[subject.length - 1];
273
+ const plural = /s$/.test(last) && !/(ss|us|is)$/.test(last);
274
+ const verb = plural ? stem : thirdPerson(stem);
275
+
276
+ const candidate = ['the', subject.join(' '), 'still', verb, tail.join(' ')]
277
+ .filter(Boolean)
278
+ .join(' ');
279
+ return refuse(candidate) ? undefined : candidate;
280
+ }
281
+
282
+ /**
283
+ * The plain form of a word if it is one of the verbs we know, else nothing.
284
+ * @param {string} word
285
+ * @returns {string|undefined}
286
+ */
287
+ function verbStem(word) {
288
+ const tries = [
289
+ word,
290
+ word.replace(/s$/, ''),
291
+ word.replace(/es$/, ''),
292
+ word.replace(/ies$/, 'y'),
293
+ word.replace(/ing$/, ''),
294
+ word.replace(/ing$/, 'e'),
295
+ word.replace(/ed$/, ''),
296
+ ];
297
+ for (const t of tries) if (t && VERBS.has(t)) return t;
298
+ return undefined;
299
+ }
300
+
301
+ /**
302
+ * Split an identifier or phrase into lower-case words.
303
+ * @param {string} s
304
+ * @returns {string[]}
305
+ */
306
+ function splitWords(s) {
307
+ return String(s)
308
+ .replace(/[_.\-#]+/g, ' ')
309
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
310
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
311
+ .toLowerCase()
312
+ .split(/\s+/)
313
+ .filter(Boolean);
314
+ }
315
+
316
+ /**
317
+ * "collapse" -> "collapses", "apply" -> "applies", "match" -> "matches".
318
+ * Already-conjugated verbs are left alone.
319
+ * @param {string} word
320
+ * @returns {string}
321
+ */
322
+ function thirdPerson(word) {
323
+ if (/s$/.test(word) && !/(ss|us|is)$/.test(word)) return word;
324
+ if (/(s|x|z|ch|sh|o)$/.test(word)) return `${word}es`;
325
+ if (/[^aeiou]y$/.test(word)) return `${word.slice(0, -1)}ies`;
326
+ return `${word}s`;
327
+ }
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Running guards.
3
+ *
4
+ * A guard is a promise that a fixed bug stays fixed, so the run is arranged
5
+ * around one idea: the result must be trustworthy on its own. Every guard starts
6
+ * from the same clean state, a guard that needs a second go is recorded as
7
+ * wobbly rather than green, and the failure message carries the story of the
8
+ * original bug so nobody has to go looking for it.
9
+ */
10
+
11
+ import { makeGuardApi, ExpectationFailed } from './api.js';
12
+ import { resetWindow } from '../drive/launch.js';
13
+
14
+ const DEFAULT_TIMEOUT = 30_000;
15
+
16
+ /**
17
+ * @typedef {import('../types.js').GuardResult & {retriedToPass?: boolean}} GuardRunResult
18
+ */
19
+
20
+ /**
21
+ * @typedef {object} AttemptOutcome
22
+ * @property {boolean} ok
23
+ * @property {string} [message]
24
+ * @property {string} [failedAt]
25
+ */
26
+
27
+ /**
28
+ * Run every guard against an app that is already open.
29
+ *
30
+ * @param {import('../types.js').Project} project
31
+ * @param {import('../types.js').LaunchedApp} app
32
+ * @param {import('../types.js').Guard[]} guards
33
+ * @param {{onResult?: (result: GuardRunResult) => void, retries?: number, signal?: AbortSignal}} [opts]
34
+ * @returns {Promise<import('../types.js').GuardResult[]>}
35
+ */
36
+ export async function runGuards(project, app, guards, opts = {}) {
37
+ const retries = Math.max(0, Math.trunc(opts.retries ?? 0));
38
+
39
+ // Electron apps have no address to go back to; for the web the configured url
40
+ // is the guard's starting line.
41
+ const baseUrl = project.config.app.kind === 'web' ? project.config.app.url : undefined;
42
+
43
+ /** @type {GuardRunResult[]} */
44
+ const results = [];
45
+
46
+ for (const guard of guards) {
47
+ // Between guards only — stopping one halfway would leave the app in a state
48
+ // the next run cannot reason about.
49
+ if (opts.signal?.aborted) break;
50
+
51
+ const startedAt = Date.now();
52
+
53
+ if (guard.skip === true) {
54
+ /** @type {GuardRunResult} */
55
+ const skipped = {
56
+ name: guard.name,
57
+ status: 'skipped',
58
+ message: 'Left out on purpose (this guard is marked skip).',
59
+ file: guard.file,
60
+ because: guard.because,
61
+ durationMs: Date.now() - startedAt,
62
+ attempts: 0,
63
+ };
64
+ results.push(skipped);
65
+ opts.onResult?.(skipped);
66
+ continue;
67
+ }
68
+
69
+ const timeoutMs = guard.timeoutMs ?? DEFAULT_TIMEOUT;
70
+ /** @type {AttemptOutcome} */
71
+ let outcome = { ok: false, message: 'This guard did not run.' };
72
+ let attempts = 0;
73
+
74
+ while (attempts < retries + 1) {
75
+ attempts += 1;
76
+ outcome = await attemptGuard(project, app, guard, baseUrl, timeoutMs);
77
+ if (outcome.ok) break;
78
+ if (opts.signal?.aborted) break;
79
+ }
80
+
81
+ /** @type {GuardRunResult} */
82
+ const result = {
83
+ name: guard.name,
84
+ status: outcome.ok ? 'passed' : 'failed',
85
+ file: guard.file,
86
+ because: guard.because,
87
+ durationMs: Date.now() - startedAt,
88
+ attempts,
89
+ };
90
+
91
+ if (outcome.ok) {
92
+ // Passing only on the second go is not passing. The flake register picks
93
+ // this up and condemns the guard, because a guard nobody trusts is worse
94
+ // than no guard: people learn to re-run it until it goes green.
95
+ if (attempts > 1) result.retriedToPass = true;
96
+ } else {
97
+ if (outcome.failedAt) result.failedAt = outcome.failedAt;
98
+ result.message = withStory(outcome.message ?? 'This guard failed.', guard.because);
99
+ }
100
+
101
+ results.push(result);
102
+ opts.onResult?.(result);
103
+ }
104
+
105
+ return results;
106
+ }
107
+
108
+ /**
109
+ * One attempt at one guard, from a clean start.
110
+ *
111
+ * @param {import('../types.js').Project} project
112
+ * @param {import('../types.js').LaunchedApp} app
113
+ * @param {import('../types.js').Guard} guard
114
+ * @param {string|undefined} baseUrl
115
+ * @param {number} timeoutMs
116
+ * @returns {Promise<AttemptOutcome>}
117
+ */
118
+ async function attemptGuard(project, app, guard, baseUrl, timeoutMs) {
119
+ /** @type {ReturnType<typeof setTimeout>|undefined} */
120
+ let timer;
121
+
122
+ try {
123
+ await Promise.race([
124
+ (async () => {
125
+ // Guards must be independent. A guard that passes only because the guard
126
+ // before it left a dialog open will lie the day somebody runs it alone
127
+ // with --only, and that is exactly the day they are trusting it.
128
+ //
129
+ // A web app has a front door to walk back through. A desktop app does not —
130
+ // and leaving that as "nothing happens" cost real time: a guard about the
131
+ // sidebar failed only when it ran after another guard, and passed on its
132
+ // own, which is the single most confusing shape a failure can take. So an
133
+ // Electron window is reloaded instead. Its main process keeps whatever it
134
+ // was holding; only the screen goes back to how it opened.
135
+ if (baseUrl) await app.page.goto(baseUrl);
136
+ else await resetWindow(app);
137
+ clearConsole(app);
138
+ await guard.run(makeGuardApi(app.page, project));
139
+ })(),
140
+ new Promise((_resolve, reject) => {
141
+ timer = setTimeout(() => {
142
+ reject(new TookTooLong(`'${guard.name}' did not finish within ${humanSeconds(timeoutMs)}.`));
143
+ }, timeoutMs);
144
+ }),
145
+ ]);
146
+ } catch (error) {
147
+ if (error instanceof ExpectationFailed) {
148
+ return {
149
+ ok: false,
150
+ failedAt: error.claim,
151
+ message: `This should still be true, and it is not: "${error.claim}".${consoleNote(app)}`,
152
+ };
153
+ }
154
+ const raw = error instanceof Error ? error.message : String(error);
155
+ return { ok: false, message: `${raw}${consoleNote(app)}` };
156
+ } finally {
157
+ // The losing side of the race keeps running otherwise, and a stray timer
158
+ // holds the process open long after the run is reported.
159
+ if (timer) clearTimeout(timer);
160
+ }
161
+
162
+ return { ok: true };
163
+ }
164
+
165
+ /** A timeout, kept apart from a real error so the wording stays ours. */
166
+ class TookTooLong extends Error {
167
+ /** @param {string} message */
168
+ constructor(message) {
169
+ super(message);
170
+ this.name = 'TookTooLong';
171
+ }
172
+ }
173
+
174
+ /**
175
+ * The page keeps console errors for whoever asks; clearing them here means the
176
+ * ones we report belong to this guard and not to the one before it.
177
+ *
178
+ * @param {import('../types.js').LaunchedApp} app
179
+ */
180
+ function clearConsole(app) {
181
+ const handle = /** @type {{clearConsole?: () => void}} */ (/** @type {unknown} */ (app.page));
182
+ handle.clearConsole?.();
183
+ }
184
+
185
+ /**
186
+ * @param {import('../types.js').LaunchedApp} app
187
+ * @returns {string}
188
+ */
189
+ function consoleNote(app) {
190
+ /** @type {string[]} */
191
+ let errors = [];
192
+ try {
193
+ errors = app.page.consoleErrors() ?? [];
194
+ } catch {
195
+ return '';
196
+ }
197
+ if (errors.length === 0) return '';
198
+ const first = String(errors[0]).split('\n')[0].slice(0, 200);
199
+ const rest = errors.length === 1 ? '' : ` (and ${errors.length - 1} more)`;
200
+ return `\nThe page also logged an error while this guard ran: ${first}${rest}`;
201
+ }
202
+
203
+ /**
204
+ * The story of the original bug is the single most useful thing to print when a
205
+ * guard fails — it says whether the failure matters.
206
+ *
207
+ * @param {string} message
208
+ * @param {string|undefined} because
209
+ * @returns {string}
210
+ */
211
+ function withStory(message, because) {
212
+ if (typeof because !== 'string' || because.trim() === '') return message;
213
+ return `${message}\n\nWhy this guard exists: ${because.trim()}`;
214
+ }
215
+
216
+ /**
217
+ * @param {number} ms
218
+ * @returns {string}
219
+ */
220
+ function humanSeconds(ms) {
221
+ if (ms < 1000) return `${Math.round(ms)} milliseconds`;
222
+ const seconds = Math.round(ms / 1000);
223
+ return seconds === 1 ? '1 second' : `${seconds} seconds`;
224
+ }
package/src/index.js ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Stays Fixed, as a library.
3
+ *
4
+ * `import { loadProject, runCheck } from 'staysfixed'` gives you exactly what
5
+ * the `staysfixed` command and the MCP server use — there is no private path
6
+ * that behaves differently. Everything here is plain ESM with no build step, so
7
+ * you can also point Node straight at `src/index.js` from a checkout.
8
+ */
9
+
10
+ /**
11
+ * Find and read a project's config.
12
+ * Everything else takes the `Project` this hands back.
13
+ */
14
+ export { loadProject } from './core/config.js';
15
+
16
+ /**
17
+ * The four nets.
18
+ *
19
+ * `runCheck` photographs the screens and runs the guards.
20
+ * `captureOne` photographs a single screen — what an agent calls right after it
21
+ * has changed something.
22
+ * `runWalk` opens the real app, walks it, and leaves a page of photos behind.
23
+ * `projectStatus` answers "what is set up here?" without opening anything.
24
+ */
25
+ export { runCheck, captureOne, runWalk, projectStatus } from './run.js';
26
+
27
+ /**
28
+ * Approving a new look.
29
+ *
30
+ * This is a person's job, always. It is exported so a review tool can offer the
31
+ * button — never so an agent can press it on its own behalf.
32
+ */
33
+ export { approveScreens } from './run.js';
34
+
35
+ /**
36
+ * Guards: one check per bug that has already been fixed once.
37
+ */
38
+ export { loadGuards } from './guard/load.js';
39
+
40
+ /**
41
+ * Markers and tracing: pin a release that was known good, then find the commit
42
+ * where a screen stopped looking like it.
43
+ */
44
+ export { writeMarker, listMarkers } from './marker/mark.js';
45
+ export { traceScreens } from './marker/trace.js';
46
+
47
+ /**
48
+ * The MCP server, so a coding agent can check its own work the moment it
49
+ * finishes editing — under the same rules, including never approving anything.
50
+ */
51
+ export { serveMcp } from './mcp/server.js';
52
+
53
+ /**
54
+ * Errors and exit codes.
55
+ * A `StaysFixedError` is a problem worth explaining to a person; anything else
56
+ * is a bug in this tool.
57
+ */
58
+ export { StaysFixedError, EXIT } from './core/errors.js';
59
+
60
+ /** The version of Stays Fixed you are running, read off its package.json. */
61
+ export { VERSION } from './run.js';