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.
- package/CHANGELOG.md +61 -0
- package/LICENSE +21 -0
- package/README.md +529 -0
- package/bin/staysfixed.js +18 -0
- package/examples/guards/the-sidebar-still-collapses.js +91 -0
- package/examples/staysfixed.config.electron.js +172 -0
- package/examples/staysfixed.config.web.js +277 -0
- package/package.json +61 -0
- package/src/cli/approve.js +126 -0
- package/src/cli/check.js +73 -0
- package/src/cli/doctor.js +379 -0
- package/src/cli/flake.js +61 -0
- package/src/cli/index.js +519 -0
- package/src/cli/init.js +564 -0
- package/src/cli/mark.js +69 -0
- package/src/cli/status.js +19 -0
- package/src/cli/trace.js +73 -0
- package/src/cli/walk.js +57 -0
- package/src/core/config.js +226 -0
- package/src/core/errors.js +48 -0
- package/src/core/git.js +90 -0
- package/src/core/hash.js +32 -0
- package/src/core/history.js +173 -0
- package/src/core/log.js +144 -0
- package/src/core/paths.js +135 -0
- package/src/drive/browser.js +540 -0
- package/src/drive/cdp.js +382 -0
- package/src/drive/electron.js +326 -0
- package/src/drive/find.js +331 -0
- package/src/drive/launch.js +263 -0
- package/src/drive/page.js +1042 -0
- package/src/freeze/clock.js +213 -0
- package/src/freeze/fonts.js +243 -0
- package/src/freeze/index.js +234 -0
- package/src/freeze/mask.js +187 -0
- package/src/freeze/motion.js +206 -0
- package/src/freeze/network.js +455 -0
- package/src/freeze/random.js +87 -0
- package/src/freeze/settle.js +178 -0
- package/src/guard/api.js +197 -0
- package/src/guard/load.js +324 -0
- package/src/guard/name.js +327 -0
- package/src/guard/run.js +224 -0
- package/src/index.js +61 -0
- package/src/marker/mark.js +260 -0
- package/src/marker/trace.js +293 -0
- package/src/mcp/server.js +377 -0
- package/src/mcp/tools.js +978 -0
- package/src/picture/capture.js +276 -0
- package/src/picture/compare.js +103 -0
- package/src/picture/run.js +284 -0
- package/src/picture/store.js +208 -0
- package/src/report/console.js +540 -0
- package/src/report/html.js +579 -0
- package/src/run.js +614 -0
- package/src/types.js +471 -0
- 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
|
+
}
|