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,260 @@
1
+ /**
2
+ * Known-good markers.
3
+ *
4
+ * A marker is a small, boring JSON file that says "on this day, at this commit,
5
+ * every screen looked like this and every guard held". It stores fingerprints,
6
+ * never copies of pictures, so pinning a release costs a couple of kilobytes.
7
+ *
8
+ * Markers are what turn "it broke sometime last week" into "it broke between
9
+ * v0.14.0 and v0.15.0, here are the nine commits". That only works if they are
10
+ * honest, so writing over one is refused unless a human insists.
11
+ */
12
+
13
+ import fsp from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import { safeName } from '../core/paths.js';
16
+ import { gitInfo } from '../core/git.js';
17
+ import { approvedHashes } from '../picture/store.js';
18
+ import { platformTag } from '../drive/find.js';
19
+ import { StaysFixedError } from '../core/errors.js';
20
+
21
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
22
+
23
+ /**
24
+ * Pin the way things look right now.
25
+ *
26
+ * @param {import('../types.js').Project} project
27
+ * @param {string} label 'v0.15.0', 'before-the-store-work' — anything a person will recognise later.
28
+ * @param {{
29
+ * note?: string,
30
+ * force?: boolean,
31
+ * at?: string,
32
+ * guards?: Record<string, import('../types.js').CheckStatus>,
33
+ * pictures?: Record<string, string>,
34
+ * run?: import('../types.js').RunSummary,
35
+ * tool?: string,
36
+ * }} [opts]
37
+ * @returns {Promise<import('../types.js').Marker>}
38
+ */
39
+ export async function writeMarker(project, label, opts = {}) {
40
+ const clean = String(label ?? '').trim();
41
+ if (!clean) {
42
+ throw new StaysFixedError('A marker needs a name.', {
43
+ hint: 'Something you will recognise in three months: `staysfixed mark v0.15.0`.',
44
+ });
45
+ }
46
+
47
+ const file = markerFile(project, clean);
48
+ if (!opts.force && (await exists(file))) {
49
+ throw new StaysFixedError(`There is already a marker called "${clean}".`, {
50
+ hint: 'Markers are history, and history should not be quietly rewritten. Use a new name, or pass --force if this one really was wrong.',
51
+ });
52
+ }
53
+
54
+ const pictures = opts.pictures ?? (await approvedHashes(project.paths));
55
+
56
+ /** @type {import('../types.js').Marker} */
57
+ const marker = {
58
+ label: clean,
59
+ at: opts.at ?? new Date().toISOString(),
60
+ git: await gitInfo(project.paths.root),
61
+ pictures,
62
+ guards: guardsFor(opts),
63
+ tool: opts.tool ?? (await toolVersion()),
64
+ platform: platformTag(),
65
+ };
66
+ if (opts.note) marker.note = opts.note;
67
+
68
+ await fsp.mkdir(project.paths.markers, { recursive: true });
69
+ await fsp.writeFile(file, JSON.stringify(marker, null, 2) + '\n');
70
+ return marker;
71
+ }
72
+
73
+ /**
74
+ * Where the guard column of a marker comes from.
75
+ *
76
+ * An explicit map wins. Otherwise we take the real statuses out of the run being
77
+ * pinned — recording a guard as passing when it did not would make the marker a
78
+ * lie, and a lying marker is worse than no marker.
79
+ *
80
+ * @param {{guards?: Record<string, import('../types.js').CheckStatus>, run?: import('../types.js').RunSummary}} opts
81
+ * @returns {Record<string, import('../types.js').CheckStatus>}
82
+ */
83
+ function guardsFor(opts) {
84
+ if (opts.guards) return { ...opts.guards };
85
+ /** @type {Record<string, import('../types.js').CheckStatus>} */
86
+ const out = {};
87
+ for (const g of opts.run?.guards ?? []) out[g.name] = g.status ?? 'passed';
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * Every marker, newest first.
93
+ * @param {import('../types.js').Project} project
94
+ * @returns {Promise<import('../types.js').Marker[]>}
95
+ */
96
+ export async function listMarkers(project) {
97
+ /** @type {string[]} */
98
+ let names;
99
+ try {
100
+ names = await fsp.readdir(project.paths.markers);
101
+ } catch {
102
+ return [];
103
+ }
104
+
105
+ /** @type {import('../types.js').Marker[]} */
106
+ const markers = [];
107
+ for (const name of names.sort()) {
108
+ if (!name.endsWith('.json')) continue;
109
+ const marker = await readMarkerFile(path.join(project.paths.markers, name));
110
+ if (marker) markers.push(marker);
111
+ }
112
+ // Newest first. Ties keep a stable order, so two markers written in the same
113
+ // second never swap places between runs.
114
+ return markers.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
115
+ }
116
+
117
+ /**
118
+ * @param {import('../types.js').Project} project
119
+ * @param {string} label
120
+ * @returns {Promise<import('../types.js').Marker|null>}
121
+ */
122
+ export async function readMarker(project, label) {
123
+ const direct = await readMarkerFile(markerFile(project, label));
124
+ if (direct) return direct;
125
+ // The file name is a tidied-up version of the label, so a person typing the
126
+ // original label with spaces or slashes in it should still find their marker.
127
+ const all = await listMarkers(project);
128
+ return all.find((m) => m.label === label) ?? null;
129
+ }
130
+
131
+ /**
132
+ * @param {import('../types.js').Project} project
133
+ * @param {string} label
134
+ * @returns {Promise<boolean>} true when a marker was actually removed
135
+ */
136
+ export async function deleteMarker(project, label) {
137
+ const file = markerFile(project, label);
138
+ if (await exists(file)) {
139
+ await fsp.rm(file, { force: true });
140
+ return true;
141
+ }
142
+ const found = (await listMarkers(project)).find((m) => m.label === label);
143
+ if (!found) return false;
144
+ const other = markerFile(project, found.label);
145
+ if (await exists(other)) {
146
+ await fsp.rm(other, { force: true });
147
+ return true;
148
+ }
149
+ return false;
150
+ }
151
+
152
+ /**
153
+ * One line a person can read at a glance:
154
+ * "v0.15.0 — 12 pictures, 8 guards, at 3f9a1c2 on main, 28 Aug 2026"
155
+ *
156
+ * @param {import('../types.js').Marker} marker
157
+ * @returns {string}
158
+ */
159
+ export function describeMarker(marker) {
160
+ const pictures = Object.keys(marker.pictures ?? {}).length;
161
+ const guards = Object.keys(marker.guards ?? {}).length;
162
+
163
+ const parts = [
164
+ `${pictures} ${pictures === 1 ? 'picture' : 'pictures'}`,
165
+ `${guards} ${guards === 1 ? 'guard' : 'guards'}`,
166
+ ];
167
+ if (marker.git?.shortSha) {
168
+ parts.push(marker.git.branch ? `at ${marker.git.shortSha} on ${marker.git.branch}` : `at ${marker.git.shortSha}`);
169
+ }
170
+ const day = readableDay(marker.at);
171
+ if (day) parts.push(day);
172
+
173
+ return `${marker.label} — ${parts.join(', ')}`;
174
+ }
175
+
176
+ /**
177
+ * @param {import('../types.js').Project} project
178
+ * @param {string} label
179
+ * @returns {string}
180
+ */
181
+ function markerFile(project, label) {
182
+ return path.join(project.paths.markers, `${safeName(label)}.json`);
183
+ }
184
+
185
+ /**
186
+ * @param {string} file
187
+ * @returns {Promise<import('../types.js').Marker|null>}
188
+ */
189
+ async function readMarkerFile(file) {
190
+ let raw;
191
+ try {
192
+ raw = await fsp.readFile(file, 'utf8');
193
+ } catch {
194
+ return null;
195
+ }
196
+ try {
197
+ const parsed = JSON.parse(raw);
198
+ if (!parsed || typeof parsed !== 'object' || typeof parsed.label !== 'string') return null;
199
+ // Older or hand-edited markers may be missing pieces; fill them in rather
200
+ // than throwing, because a half-readable marker still narrows a search.
201
+ return {
202
+ label: parsed.label,
203
+ at: typeof parsed.at === 'string' ? parsed.at : '',
204
+ note: typeof parsed.note === 'string' ? parsed.note : undefined,
205
+ git: parsed.git ?? { sha: null, shortSha: null, branch: null, dirty: false, user: null },
206
+ pictures: parsed.pictures ?? {},
207
+ guards: parsed.guards ?? {},
208
+ tool: typeof parsed.tool === 'string' ? parsed.tool : 'unknown',
209
+ platform: typeof parsed.platform === 'string' ? parsed.platform : 'unknown',
210
+ };
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
216
+ /**
217
+ * '2026-08-28T21:03:00.000Z' -> '28 Aug 2026'. Read in UTC so the same marker
218
+ * reads the same on every machine that opens it.
219
+ * @param {string} iso
220
+ * @returns {string|null}
221
+ */
222
+ function readableDay(iso) {
223
+ const t = Date.parse(iso);
224
+ if (Number.isNaN(t)) return null;
225
+ const d = new Date(t);
226
+ return `${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
227
+ }
228
+
229
+ /**
230
+ * @param {string} file
231
+ * @returns {Promise<boolean>}
232
+ */
233
+ async function exists(file) {
234
+ try {
235
+ await fsp.stat(file);
236
+ return true;
237
+ } catch {
238
+ return false;
239
+ }
240
+ }
241
+
242
+ /** @type {string|null} */
243
+ let cachedVersion = null;
244
+
245
+ /**
246
+ * Which build of the tool wrote this marker. Read off package.json rather than
247
+ * hard-coded, so it can never drift from the version that actually shipped.
248
+ * @returns {Promise<string>}
249
+ */
250
+ async function toolVersion() {
251
+ if (cachedVersion !== null) return cachedVersion;
252
+ try {
253
+ const raw = await fsp.readFile(new URL('../../package.json', import.meta.url), 'utf8');
254
+ const parsed = JSON.parse(raw);
255
+ cachedVersion = typeof parsed.version === 'string' ? `staysfixed ${parsed.version}` : 'staysfixed';
256
+ } catch {
257
+ cachedVersion = 'staysfixed';
258
+ }
259
+ return cachedVersion;
260
+ }
@@ -0,0 +1,293 @@
1
+ /**
2
+ * "Which change broke this?"
3
+ *
4
+ * Markers hold a fingerprint of every screen at moments a human trusted. So when
5
+ * a screen looks wrong today, we can walk backwards through the markers to find
6
+ * the newest one where it still looked the way it does now, and the very next one
7
+ * where it did not. The commits between those two are the suspects — usually a
8
+ * handful, instead of a week of work.
9
+ *
10
+ * The honest answer is often "I cannot tell", and this file says so out loud
11
+ * rather than guessing. A trace that points at the wrong commit costs more time
12
+ * than no trace at all.
13
+ */
14
+
15
+ import { listMarkers } from './mark.js';
16
+ import { approvedHashes } from '../picture/store.js';
17
+ import { commitsBetween, filesBetween, commitExists } from '../core/git.js';
18
+
19
+ /** More than this many commits is a wall of text, not an answer. */
20
+ const COMMIT_CAP = 40;
21
+
22
+ /**
23
+ * @param {import('../types.js').Project} project
24
+ * @param {{names?: string[], current?: Record<string,string>}} [opts]
25
+ * `current` defaults to the fingerprints of today's approved pictures. When
26
+ * tracing a live failure the CLI passes the fingerprints of the pictures the
27
+ * failing run actually took, so the trace follows the broken thing, not the
28
+ * thing that was approved.
29
+ * @returns {Promise<import('../types.js').TraceReport>}
30
+ */
31
+ export async function traceScreens(project, opts = {}) {
32
+ const markers = await listMarkers(project);
33
+ const current = opts.current ?? (await approvedHashes(project.paths));
34
+ const names =
35
+ opts.names && opts.names.length > 0 ? [...opts.names] : Object.keys(current).sort();
36
+
37
+ /** @type {import('../types.js').TraceFinding[]} */
38
+ const findings = [];
39
+ for (const name of names) {
40
+ findings.push(await traceOne(project, markers, name, current[name]));
41
+ }
42
+
43
+ /** @type {import('../types.js').TraceReport} */
44
+ const report = { findings, markersSearched: markers.length };
45
+ if (markers.length === 0) {
46
+ report.message =
47
+ 'There are no known-good markers in this project yet, so there is no history to search. Mark a release you trust with `staysfixed mark v1.2.3` and the next trace will have something to work with.';
48
+ } else if (names.length === 0) {
49
+ report.message = 'There are no approved pictures to trace.';
50
+ }
51
+ return report;
52
+ }
53
+
54
+ /**
55
+ * @param {import('../types.js').Project} project
56
+ * @param {import('../types.js').Marker[]} markers newest first
57
+ * @param {string} name
58
+ * @param {string|undefined} now fingerprint of how the screen looks right now
59
+ * @returns {Promise<import('../types.js').TraceFinding>}
60
+ */
61
+ async function traceOne(project, markers, name, now) {
62
+ const who = readable(name);
63
+
64
+ if (now === undefined) {
65
+ return {
66
+ name,
67
+ verdict: 'unknown',
68
+ message: `There is no picture of ${who} to compare against — nothing has been approved for it yet.`,
69
+ };
70
+ }
71
+ if (markers.length === 0) {
72
+ return {
73
+ name,
74
+ verdict: 'unknown',
75
+ message: `There are no known-good markers yet, so there is nothing to compare ${who} against.`,
76
+ };
77
+ }
78
+
79
+ const lastGoodIndex = markers.findIndex((m) => m.pictures?.[name] === now);
80
+ if (lastGoodIndex === -1) {
81
+ const oldest = markers[markers.length - 1];
82
+ return {
83
+ name,
84
+ verdict: 'unknown',
85
+ message: `None of the ${markers.length} markers show ${who} looking the way it does now. Either it is brand new, or it changed before the oldest marker (${oldest.label}).`,
86
+ };
87
+ }
88
+
89
+ const lastGood = markers[lastGoodIndex];
90
+ if (lastGoodIndex === 0) {
91
+ return {
92
+ name,
93
+ verdict: 'unchanged',
94
+ lastGood,
95
+ message: `${capitalise(who)} looks exactly as it did at ${lastGood.label}, the newest marker.`,
96
+ };
97
+ }
98
+
99
+ // The boundary is the first marker written AFTER lastGood that actually
100
+ // recorded this screen and recorded it differently. Markers that never saw
101
+ // this screen are skipped rather than blamed.
102
+ let firstBad;
103
+ for (let j = lastGoodIndex - 1; j >= 0; j--) {
104
+ const seen = markers[j].pictures?.[name];
105
+ if (seen !== undefined && seen !== now) {
106
+ firstBad = markers[j];
107
+ break;
108
+ }
109
+ }
110
+ if (!firstBad) {
111
+ return {
112
+ name,
113
+ verdict: 'unknown',
114
+ lastGood,
115
+ message: `${capitalise(who)} last matched at ${lastGood.label}, and no marker written after that one recorded this screen at all.`,
116
+ };
117
+ }
118
+
119
+ const from = lastGood.git?.sha;
120
+ const to = firstBad.git?.sha;
121
+ if (!from || !to) {
122
+ const which = !from ? lastGood.label : firstBad.label;
123
+ return {
124
+ name,
125
+ verdict: 'unknown',
126
+ lastGood,
127
+ firstBad,
128
+ message: `${capitalise(who)} changed between ${lastGood.label} and ${firstBad.label}, but ${which} was not marked inside a git repository, so there is no list of commits to show.`,
129
+ };
130
+ }
131
+
132
+ const root = project.paths.root;
133
+ const missing = [];
134
+ if (!(await commitExists(root, from))) missing.push(`${lastGood.label} (${short(from)})`);
135
+ if (!(await commitExists(root, to))) missing.push(`${firstBad.label} (${short(to)})`);
136
+ if (missing.length > 0) {
137
+ return {
138
+ name,
139
+ verdict: 'unknown',
140
+ lastGood,
141
+ firstBad,
142
+ message: `${capitalise(who)} changed between ${lastGood.label} and ${firstBad.label}, but git here cannot find ${missing.join(' or ')}. That usually means the branch was rebuilt or this is a shallow clone.`,
143
+ };
144
+ }
145
+
146
+ const allCommits = await commitsBetween(root, from, to);
147
+ const files = await filesBetween(root, from, to);
148
+ const commits = allCommits.slice(0, COMMIT_CAP);
149
+
150
+ let message = `${capitalise(who)} looked right at ${lastGood.label} and wrong by ${firstBad.label}. ${countOf(allCommits.length, 'commit')} landed in between`;
151
+ message += files.length > 0 ? `, touching ${countOf(files.length, 'file')}.` : '.';
152
+ if (allCommits.length > commits.length) {
153
+ // A silent cap reads as "that's all of them", which would send someone
154
+ // looking in the wrong half of the range.
155
+ message += ` Only the newest ${COMMIT_CAP} commits are listed here.`;
156
+ }
157
+
158
+ return { name, verdict: 'changed', lastGood, firstBad, commits, files, message };
159
+ }
160
+
161
+ /**
162
+ * One plain sentence about a single finding.
163
+ *
164
+ * @param {import('../types.js').TraceFinding} finding
165
+ * @returns {string}
166
+ */
167
+ export function summariseTrace(finding) {
168
+ const who = readable(finding.name);
169
+
170
+ if (finding.verdict === 'unchanged') {
171
+ return finding.lastGood
172
+ ? `${who} still looks the way it did at ${finding.lastGood.label}.`
173
+ : `${who} has not changed.`;
174
+ }
175
+
176
+ if (finding.verdict === 'changed' && finding.lastGood && finding.firstBad) {
177
+ const commits = finding.commits ?? [];
178
+ let line = `${who} looked right at ${finding.lastGood.label} and wrong by ${finding.firstBad.label} — ${countOf(commits.length, 'commit')} in between`;
179
+ const hot = busiestFolder(finding.files ?? []);
180
+ if (hot) {
181
+ line += hot.all
182
+ ? `, and all ${hot.count} changed files are under ${hot.dir}.`
183
+ : `, and ${hot.count} of the changed files are under ${hot.dir}.`;
184
+ } else {
185
+ line += '.';
186
+ }
187
+ return line;
188
+ }
189
+
190
+ return finding.message ?? `There is not enough history to say when ${who} changed.`;
191
+ }
192
+
193
+ /**
194
+ * The folder the changed files sit in — the quickest hint about where to look
195
+ * first. Counting is inclusive of sub-folders, so the sentence it feeds is
196
+ * literally true: saying "5 of the files are under src" while nine of them are
197
+ * would send someone looking in the wrong place.
198
+ *
199
+ * @param {string[]} files
200
+ * @returns {{dir: string, count: number, all: boolean}|null}
201
+ */
202
+ function busiestFolder(files) {
203
+ if (files.length < 2) return null;
204
+
205
+ const shared = sharedFolder(files);
206
+ if (shared) return { dir: shared, count: files.length, all: true };
207
+
208
+ /** @type {Map<string, number>} */
209
+ const counts = new Map();
210
+ for (const file of files) {
211
+ const parts = file.split('/');
212
+ for (let depth = 1; depth <= Math.min(2, parts.length - 1); depth++) {
213
+ const dir = parts.slice(0, depth).join('/');
214
+ counts.set(dir, (counts.get(dir) ?? 0) + 1);
215
+ }
216
+ }
217
+
218
+ /** @type {{dir: string, count: number, all: boolean}|null} */
219
+ let best = null;
220
+ for (const [dir, count] of counts) {
221
+ // A deeper folder wins a tie: 'src/renderer' is an answer, 'src' is a shrug.
222
+ const better = !best || count > best.count || (count === best.count && dir.length > best.dir.length);
223
+ if (better) best = { dir, count, all: false };
224
+ }
225
+ return best && best.count >= 2 ? best : null;
226
+ }
227
+
228
+ /**
229
+ * The deepest folder that contains every one of these files, or null when they
230
+ * are scattered across the repository.
231
+ * @param {string[]} files
232
+ * @returns {string|null}
233
+ */
234
+ function sharedFolder(files) {
235
+ /** @type {string[]|null} */
236
+ let common = null;
237
+ for (const file of files) {
238
+ const parts = file.split('/').slice(0, -1);
239
+ if (common === null) {
240
+ common = parts;
241
+ continue;
242
+ }
243
+ let i = 0;
244
+ while (i < common.length && i < parts.length && common[i] === parts[i]) i++;
245
+ common = common.slice(0, i);
246
+ if (common.length === 0) return null;
247
+ }
248
+ return common && common.length > 0 ? common.join('/') : null;
249
+ }
250
+
251
+ /**
252
+ * @param {number} n
253
+ * @param {string} one
254
+ * @param {string} [many]
255
+ * @returns {string}
256
+ */
257
+ function countOf(n, one, many) {
258
+ if (n === 0) return `no ${many ?? one + 's'}`;
259
+ return `${n} ${n === 1 ? one : many ?? one + 's'}`;
260
+ }
261
+
262
+ /**
263
+ * 'sessions-list' -> 'the sessions list'. Screen names are file-safe ids; people
264
+ * are not, so nothing a person reads should be an id.
265
+ * @param {string} name
266
+ * @returns {string}
267
+ */
268
+ function readable(name) {
269
+ const words = String(name ?? '')
270
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
271
+ .replace(/[-_.]+/g, ' ')
272
+ .trim()
273
+ .toLowerCase()
274
+ .replace(/\s+/g, ' ');
275
+ if (!words) return 'this screen';
276
+ return /^(the|a|an) /.test(words) ? words : `the ${words}`;
277
+ }
278
+
279
+ /**
280
+ * @param {string} s
281
+ * @returns {string}
282
+ */
283
+ function capitalise(s) {
284
+ return s.charAt(0).toUpperCase() + s.slice(1);
285
+ }
286
+
287
+ /**
288
+ * @param {string} sha
289
+ * @returns {string}
290
+ */
291
+ function short(sha) {
292
+ return sha.slice(0, 7);
293
+ }