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,173 @@
1
+ /**
2
+ * The flake register.
3
+ *
4
+ * Asad's rule, and it is the right one: a check that flakes twice gets fixed or
5
+ * deleted, never tolerated. So the tool has to remember. Every run appends a
6
+ * status per check; when a check changes its mind while the code stood still,
7
+ * that is a flake. Past the limit the check is condemned and `check` says so in
8
+ * red until a human deals with it.
9
+ */
10
+
11
+ import fsp from 'node:fs/promises';
12
+ import path from 'node:path';
13
+
14
+ const KEEP_RECENT = 12;
15
+
16
+ /**
17
+ * @param {string} file
18
+ * @returns {Promise<import('../types.js').History>}
19
+ */
20
+ export async function loadHistory(file) {
21
+ try {
22
+ const raw = await fsp.readFile(file, 'utf8');
23
+ const parsed = JSON.parse(raw);
24
+ return parsed && typeof parsed === 'object' ? parsed : {};
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+
30
+ /**
31
+ * @param {string} file
32
+ * @param {import('../types.js').History} history
33
+ */
34
+ export async function saveHistory(file, history) {
35
+ await fsp.mkdir(path.dirname(file), { recursive: true });
36
+ await fsp.writeFile(file, JSON.stringify(history, null, 2) + '\n');
37
+ }
38
+
39
+ /**
40
+ * Fold one run into the register.
41
+ *
42
+ * A flake is: this check passed and then failed (or the reverse) while the git
43
+ * sha and the working-tree state did not change. That is the only honest
44
+ * definition — anything else blames the developer for their own edits.
45
+ *
46
+ * @param {import('../types.js').History} history
47
+ * @param {{name: string, kind: 'picture'|'guard', status: import('../types.js').CheckStatus, retriedToPass?: boolean}[]} results
48
+ * @param {import('../types.js').GitInfo} git
49
+ * @param {string} at ISO timestamp
50
+ * @param {number} flakeLimit
51
+ * @returns {{history: import('../types.js').History, newlyCondemned: string[], flakedNow: string[]}}
52
+ */
53
+ export function foldRun(history, results, git, at, flakeLimit) {
54
+ const next = /** @type {import('../types.js').History} */ (structuredClone(history));
55
+ const newlyCondemned = [];
56
+ const flakedNow = [];
57
+ // What "the code did not change" means.
58
+ //
59
+ // Only a clean tree at a known commit proves it. Without git there is no way to tell a
60
+ // wobble from an edit, and guessing costs more than it gives: treating consecutive runs
61
+ // in a repo-less folder as "the same state" made a deliberately broken stylesheet, and
62
+ // then its repair, register as two flakes on every screen in the project — the register
63
+ // shouting about eleven perfectly good checks. A false accusation of flakiness is
64
+ // exactly as corrosive as a false failure.
65
+ //
66
+ // So a run with no git evidence still records its status, and still catches the
67
+ // unambiguous signal (a check that needed a retry to pass INSIDE one run). It just does
68
+ // not compare across runs. `staysfixed flake` says so out loud rather than looking
69
+ // clean — see `blindWithoutGit`.
70
+ const stamp = git.dirty ? null : git.sha;
71
+
72
+ for (const r of results) {
73
+ const key = `${r.kind}:${r.name}`;
74
+ const entry = next[key] ?? {
75
+ name: r.name,
76
+ kind: r.kind,
77
+ runs: 0,
78
+ flakes: 0,
79
+ recent: [],
80
+ };
81
+
82
+ const previous = entry.recent.length ? entry.recent[entry.recent.length - 1] : null;
83
+ const previousSha = /** @type {any} */ (entry).lastSha ?? null;
84
+
85
+ // Two ways to catch a wobble:
86
+ // 1. it needed a retry to pass inside this very run — unambiguous;
87
+ // 2. it flipped between runs at the same commit, clean tree both times.
88
+ const flippedAtSameCommit =
89
+ previous !== null &&
90
+ previous !== r.status &&
91
+ stamp !== null &&
92
+ previousSha === stamp &&
93
+ isDecided(previous) &&
94
+ isDecided(r.status);
95
+
96
+ if (r.retriedToPass || flippedAtSameCommit) {
97
+ entry.flakes += 1;
98
+ entry.lastFlakeAt = at;
99
+ entry.lastFlakeGitSha = git.sha ?? undefined;
100
+ flakedNow.push(r.name);
101
+ if (entry.flakes >= flakeLimit && !entry.condemned) {
102
+ entry.condemned = true;
103
+ newlyCondemned.push(r.name);
104
+ }
105
+ }
106
+
107
+ entry.runs += 1;
108
+ entry.recent = [...entry.recent, r.status].slice(-KEEP_RECENT);
109
+ /** @type {any} */ (entry).lastSha = stamp;
110
+ next[key] = entry;
111
+ }
112
+
113
+ return { history: next, newlyCondemned, flakedNow };
114
+ }
115
+
116
+ /**
117
+ * A status that means the check actually reached a verdict.
118
+ * @param {import('../types.js').CheckStatus} s
119
+ */
120
+ function isDecided(s) {
121
+ return s === 'passed' || s === 'changed' || s === 'failed';
122
+ }
123
+
124
+ /**
125
+ * @param {import('../types.js').History} history
126
+ * @returns {import('../types.js').HistoryEntry[]}
127
+ */
128
+ export function condemned(history) {
129
+ return Object.values(history).filter((e) => e.condemned);
130
+ }
131
+
132
+ /**
133
+ * @param {import('../types.js').History} history
134
+ * @returns {import('../types.js').HistoryEntry[]}
135
+ */
136
+ export function wobbly(history) {
137
+ return Object.values(history)
138
+ .filter((e) => e.flakes > 0)
139
+ .sort((a, b) => b.flakes - a.flakes);
140
+ }
141
+
142
+ /**
143
+ * Forgive a check — used by `staysfixed flake --clear <name>` once it has been fixed.
144
+ * @param {import('../types.js').History} history
145
+ * @param {string} name
146
+ * @returns {import('../types.js').History}
147
+ */
148
+ export function clearFlakes(history, name) {
149
+ const next = /** @type {import('../types.js').History} */ (structuredClone(history));
150
+ for (const [key, entry] of Object.entries(next)) {
151
+ if (entry.name === name || key === name) {
152
+ entry.flakes = 0;
153
+ entry.condemned = false;
154
+ delete entry.lastFlakeAt;
155
+ delete entry.lastFlakeGitSha;
156
+ }
157
+ }
158
+ return next;
159
+ }
160
+
161
+ /**
162
+ * Whether the register can only see wobbles inside a single run.
163
+ *
164
+ * True when there is no commit to pin a status to — no git, or a dirty tree. The `flake`
165
+ * command prints this, because a register that looks empty for the wrong reason is worse
166
+ * than one that admits what it cannot see.
167
+ *
168
+ * @param {import('../types.js').GitInfo} git
169
+ * @returns {boolean}
170
+ */
171
+ export function blindWithoutGit(git) {
172
+ return git.sha === null || git.dirty;
173
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Terminal output. No dependencies, no spinners that break in CI, no jargon.
3
+ *
4
+ * Everything a human reads comes through here, so the voice stays the same
5
+ * whether it is printed by `check`, by `walk`, or quoted back by the MCP server.
6
+ */
7
+
8
+ const noColor =
9
+ process.env.NO_COLOR !== undefined ||
10
+ process.env.STAYSFIXED_NO_COLOR !== undefined ||
11
+ process.env.TERM === 'dumb';
12
+
13
+ const tty = Boolean(process.stdout.isTTY) && !noColor;
14
+
15
+ /** @type {(code: string) => (s: string) => string} */
16
+ const wrap = (code) => (s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : String(s));
17
+
18
+ export const paint = {
19
+ bold: wrap('1'),
20
+ dim: wrap('2'),
21
+ red: wrap('31'),
22
+ green: wrap('32'),
23
+ yellow: wrap('33'),
24
+ blue: wrap('34'),
25
+ magenta: wrap('35'),
26
+ cyan: wrap('36'),
27
+ grey: wrap('90'),
28
+ };
29
+
30
+ /** Symbols that survive a plain terminal. */
31
+ export const mark = {
32
+ pass: tty ? '✓' : 'ok',
33
+ fail: tty ? '✗' : 'X',
34
+ warn: tty ? '!' : '!',
35
+ info: tty ? '·' : '-',
36
+ arrow: tty ? '→' : '->',
37
+ };
38
+
39
+ let quiet = false;
40
+ let verbose = false;
41
+
42
+ /** @param {{quiet?: boolean, verbose?: boolean}} opts */
43
+ export function setLogLevel(opts) {
44
+ if (opts.quiet !== undefined) quiet = opts.quiet;
45
+ if (opts.verbose !== undefined) verbose = opts.verbose;
46
+ }
47
+
48
+ export function isVerbose() {
49
+ return verbose;
50
+ }
51
+
52
+ /** @param {...unknown} args */
53
+ export function say(...args) {
54
+ if (!quiet) process.stdout.write(args.map(String).join(' ') + '\n');
55
+ }
56
+
57
+ /** @param {...unknown} args */
58
+ export function detail(...args) {
59
+ if (verbose && !quiet) process.stdout.write(paint.grey(args.map(String).join(' ')) + '\n');
60
+ }
61
+
62
+ /** @param {...unknown} args */
63
+ export function warn(...args) {
64
+ process.stderr.write(paint.yellow(`${mark.warn} ` + args.map(String).join(' ')) + '\n');
65
+ }
66
+
67
+ /** @param {...unknown} args */
68
+ export function fail(...args) {
69
+ process.stderr.write(paint.red(`${mark.fail} ` + args.map(String).join(' ')) + '\n');
70
+ }
71
+
72
+ /** @param {...unknown} args */
73
+ export function ok(...args) {
74
+ if (!quiet) process.stdout.write(paint.green(`${mark.pass} `) + args.map(String).join(' ') + '\n');
75
+ }
76
+
77
+ export function blank() {
78
+ if (!quiet) process.stdout.write('\n');
79
+ }
80
+
81
+ /**
82
+ * A heading with a rule under it.
83
+ * @param {string} text
84
+ */
85
+ export function heading(text) {
86
+ if (quiet) return;
87
+ process.stdout.write('\n' + paint.bold(text) + '\n');
88
+ }
89
+
90
+ /**
91
+ * Print rows as an aligned table. Values are strings already painted if needed.
92
+ * @param {string[][]} rows
93
+ * @param {{indent?: number}} [opts]
94
+ */
95
+ export function table(rows, opts = {}) {
96
+ if (quiet || rows.length === 0) return;
97
+ const indent = ' '.repeat(opts.indent ?? 0);
98
+ const widths = /** @type {number[]} */ ([]);
99
+ for (const row of rows) {
100
+ row.forEach((cell, i) => {
101
+ const w = visibleWidth(cell);
102
+ if (widths[i] === undefined || w > widths[i]) widths[i] = w;
103
+ });
104
+ }
105
+ for (const row of rows) {
106
+ const line = row
107
+ .map((cell, i) => (i === row.length - 1 ? cell : cell + ' '.repeat(widths[i] - visibleWidth(cell))))
108
+ .join(' ');
109
+ process.stdout.write(indent + line.trimEnd() + '\n');
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Width ignoring ANSI colour codes.
115
+ * @param {string} s
116
+ */
117
+ export function visibleWidth(s) {
118
+ // eslint-disable-next-line no-control-regex
119
+ return String(s).replace(/\x1b\[[0-9;]*m/g, '').length;
120
+ }
121
+
122
+ /**
123
+ * Human duration: 900 -> "0.9s", 65000 -> "1m 5s".
124
+ * @param {number} ms
125
+ */
126
+ export function duration(ms) {
127
+ if (ms < 1000) return `${Math.round(ms)}ms`;
128
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
129
+ const m = Math.floor(ms / 60_000);
130
+ const s = Math.round((ms % 60_000) / 1000);
131
+ return `${m}m ${s}s`;
132
+ }
133
+
134
+ /**
135
+ * A path shortened for reading, relative to cwd when it is inside it.
136
+ * @param {string} p
137
+ */
138
+ export function shortPath(p) {
139
+ const cwd = process.cwd();
140
+ if (p.startsWith(cwd + '/')) return p.slice(cwd.length + 1);
141
+ const home = process.env.HOME;
142
+ if (home && p.startsWith(home + '/')) return '~/' + p.slice(home.length + 1);
143
+ return p;
144
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Where everything lives inside a project.
3
+ *
4
+ * Committed (belongs in git): approved/ guards/ markers/ fixtures/ config
5
+ * Not committed (throwaway): results/ diffs/ report.html last-run.json
6
+ *
7
+ * The split matters: approved pictures are the promise, results are just the
8
+ * evidence from the last run.
9
+ */
10
+
11
+ import path from 'node:path';
12
+ import fs from 'node:fs';
13
+ import fsp from 'node:fs/promises';
14
+
15
+ export const CONFIG_NAMES = [
16
+ 'staysfixed.config.js',
17
+ 'staysfixed.config.mjs',
18
+ 'staysfixed.config.json',
19
+ '.staysfixed/config.js',
20
+ '.staysfixed/config.mjs',
21
+ '.staysfixed/config.json',
22
+ ];
23
+
24
+ export const DEFAULT_DIR = '.staysfixed';
25
+
26
+ /**
27
+ * Walk up from `from` looking for a config file.
28
+ * @param {string} [from]
29
+ * @returns {string|null} absolute path to the config file, or null
30
+ */
31
+ export function findConfigFile(from = process.cwd()) {
32
+ let dir = path.resolve(from);
33
+ for (;;) {
34
+ for (const name of CONFIG_NAMES) {
35
+ const candidate = path.join(dir, name);
36
+ if (fs.existsSync(candidate)) return candidate;
37
+ }
38
+ const parent = path.dirname(dir);
39
+ if (parent === dir) return null;
40
+ dir = parent;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * The project root for a config file: the folder holding it, or its parent
46
+ * when the config lives inside `.staysfixed/`.
47
+ * @param {string} configFile
48
+ */
49
+ export function rootForConfig(configFile) {
50
+ const dir = path.dirname(configFile);
51
+ return path.basename(dir) === DEFAULT_DIR ? path.dirname(dir) : dir;
52
+ }
53
+
54
+ /**
55
+ * @param {string} root
56
+ * @param {string} configFile
57
+ * @param {string} [dirName]
58
+ * @returns {import('../types.js').ProjectPaths}
59
+ */
60
+ export function pathsFor(root, configFile, dirName = DEFAULT_DIR) {
61
+ const dir = path.isAbsolute(dirName) ? dirName : path.join(root, dirName);
62
+ return {
63
+ root,
64
+ dir,
65
+ approved: path.join(dir, 'approved'),
66
+ results: path.join(dir, 'results'),
67
+ diffs: path.join(dir, 'results', 'diffs'),
68
+ markers: path.join(dir, 'markers'),
69
+ guards: path.join(dir, 'guards'),
70
+ fixtures: path.join(dir, 'fixtures'),
71
+ historyFile: path.join(dir, 'history.json'),
72
+ reportFile: path.join(dir, 'report.html'),
73
+ configFile,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Create the folders that must exist before a run.
79
+ * @param {import('../types.js').ProjectPaths} paths
80
+ */
81
+ export async function ensureDirs(paths) {
82
+ for (const d of [paths.dir, paths.approved, paths.results, paths.diffs, paths.markers]) {
83
+ await fsp.mkdir(d, { recursive: true });
84
+ }
85
+ }
86
+
87
+ /**
88
+ * File name for an approved picture and its metadata.
89
+ * @param {import('../types.js').ProjectPaths} paths
90
+ * @param {string} name
91
+ */
92
+ export function approvedPicture(paths, name) {
93
+ return {
94
+ png: path.join(paths.approved, `${safeName(name)}.png`),
95
+ json: path.join(paths.approved, `${safeName(name)}.json`),
96
+ };
97
+ }
98
+
99
+ /**
100
+ * File names for this run's output for one screen.
101
+ * @param {import('../types.js').ProjectPaths} paths
102
+ * @param {string} name
103
+ */
104
+ export function resultPicture(paths, name) {
105
+ return {
106
+ png: path.join(paths.results, `${safeName(name)}.png`),
107
+ diff: path.join(paths.diffs, `${safeName(name)}.diff.png`),
108
+ };
109
+ }
110
+
111
+ /**
112
+ * Screen names become file names, so keep them boring.
113
+ * @param {string} name
114
+ */
115
+ export function safeName(name) {
116
+ return String(name)
117
+ .trim()
118
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
119
+ .replace(/^-+|-+$/g, '')
120
+ .slice(0, 120) || 'unnamed';
121
+ }
122
+
123
+ /**
124
+ * Clear the previous run's evidence so a stale diff can never be mistaken for a fresh one.
125
+ * @param {import('../types.js').ProjectPaths} paths
126
+ */
127
+ export async function clearResults(paths) {
128
+ await fsp.rm(paths.results, { recursive: true, force: true });
129
+ await fsp.mkdir(paths.diffs, { recursive: true });
130
+ }
131
+
132
+ /**
133
+ * The .gitignore lines a project needs. Written by `init`, checked by `doctor`.
134
+ */
135
+ export const GITIGNORE_LINES = ['# Stays Fixed — evidence from the last run, not the promise', '.staysfixed/results/', '.staysfixed/report.html'];