staysfixed 0.3.0 → 0.4.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 (47) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
  47. package/src/watch/panel.js +73 -44
package/src/v2/ci.js ADDED
@@ -0,0 +1,1209 @@
1
+ /**
2
+ * Running it where merges happen.
3
+ *
4
+ * A check that only runs on the author's laptop catches what the author was already
5
+ * looking for. The same check running on every pull request catches what nobody was
6
+ * looking for, which is the entire class of thing this tool exists to find. So this file
7
+ * is the part that makes a build server a first-class place to run from.
8
+ *
9
+ * CI IS NOT A WORSE MACHINE THAN A LAPTOP. It is a better one, for this specific job. A
10
+ * fresh runner has the same fonts every time, the same operating system, nothing else
11
+ * competing for a port or for memory, and no half-finished experiment left over from
12
+ * yesterday afternoon. Everything version 2 does rests on "the difference was caused by the
13
+ * change and nothing else", and a machine that is identical on every run is worth a great
14
+ * deal to that claim.
15
+ *
16
+ * THE HARD PART IS THE REFERENCE, and it is worth stating why before reading any code.
17
+ * On a laptop the reference is a build the owner shipped, remembered in a folder that has
18
+ * been sitting there for weeks. A fresh runner has no folder and no memory. So the
19
+ * reference has to be reconstructed out of what a build server does have, which is git —
20
+ * and git turns out to be enough, because `check` accepts a commit and puts that commit
21
+ * back on the machine with `git archive` before walking it. A pull request compared
22
+ * against the commit it forked from is a FULL PAIRED RUN: two builds, one runner, minutes
23
+ * apart. That is the strongest answer this tool can give, and it is available in CI from a
24
+ * bare checkout with no stored record at all.
25
+ *
26
+ * Not every event can reach that, so `referenceForCI` works down a ranked list and says
27
+ * out loud which rung it landed on. The modes are NOT equally strong and this file never
28
+ * pretends they are: every report carries the mode, how it was found, and what would have
29
+ * made it stronger.
30
+ *
31
+ * WHAT THIS FILE MAY NEVER DO: approve anything. It does not cut a reference, it does not
32
+ * write a waiver, it does not record a build as checked in a way that would let one be cut
33
+ * later. CI reports; a person ships. That line is the whole safeguard and a build server
34
+ * sits on the wrong side of it — a green pipeline is not somebody saying "that is what my
35
+ * product does now". So there is deliberately no import of `cutReference` or `setReference`
36
+ * anywhere below, and there never should be.
37
+ */
38
+
39
+ import fs from 'node:fs';
40
+ import fsp from 'node:fs/promises';
41
+ import path from 'node:path';
42
+ import { execFile } from 'node:child_process';
43
+ import { promisify } from 'node:util';
44
+ import { fileURLToPath } from 'node:url';
45
+
46
+ import { EXIT, messageOf } from '../core/errors.js';
47
+ import { check } from './check.js';
48
+ import { openStore, listBuilds, referencePointer } from './store.js';
49
+
50
+ const exec = promisify(execFile);
51
+
52
+ /** @typedef {import('./types.js').Finding} Finding */
53
+ /** @typedef {import('./check.js').CheckOutcome} CheckOutcome */
54
+
55
+ /** A plain map of environment variables. `process.env` is one; a fake one in a test is another. */
56
+ /** @typedef {Record<string, string|undefined>} Env */
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // What the environment says
60
+ // ---------------------------------------------------------------------------
61
+
62
+ /**
63
+ * Which build server this is, or none.
64
+ *
65
+ * `none` also covers "something set CI=true and we do not recognise it". That is deliberate:
66
+ * claiming to be on a build server we cannot read the variables of would produce a confident
67
+ * wrong answer about the base commit, which is worse than admitting we do not know.
68
+ *
69
+ * @typedef {'github'|'gitlab'|'circleci'|'none'} CIProvider
70
+ */
71
+
72
+ /**
73
+ * What a pull request is, said the same way whichever server described it.
74
+ *
75
+ * @typedef {object} CIPullRequest
76
+ * @property {string|null} number The number a person would quote.
77
+ * @property {string|null} base The branch it is aimed at, by name: 'main'.
78
+ * @property {string|null} baseSha The base commit, when the server told us one outright.
79
+ * @property {string|null} headSha The tip of the branch being proposed.
80
+ */
81
+
82
+ /**
83
+ * What this machine says about itself.
84
+ *
85
+ * @typedef {object} CIEnvironment
86
+ * @property {boolean} on Are we on a build server at all.
87
+ * @property {CIProvider} provider
88
+ * @property {string} name What a person calls it: 'GitHub Actions'.
89
+ * @property {string|null} commit The commit being built. See the note on GitHub below.
90
+ * @property {string|null} branch
91
+ * @property {string|null} repo 'owner/name', when the server says.
92
+ * @property {CIPullRequest|null} pullRequest
93
+ * @property {string|null} beforeSha The commit that was on the branch before this push.
94
+ * @property {string|null} runId
95
+ * @property {string|null} runUrl A link a person can open.
96
+ * @property {string|null} summaryFile A file the job summary is appended to, if there is one.
97
+ * @property {string} note One plain sentence describing all of the above.
98
+ */
99
+
100
+ /**
101
+ * Read what the environment says about the commit, the branch, the pull request and the base.
102
+ *
103
+ * ONE GOTCHA WORTH THE PARAGRAPH. On GitHub, a `pull_request` event does not build the tip of
104
+ * your branch: it builds a temporary merge of your branch into the base, and `GITHUB_SHA`
105
+ * names that merge commit, which exists nowhere in anybody's history. The tip of the branch
106
+ * is in the event file under `pull_request.head.sha`. Getting this wrong makes every report
107
+ * name a commit nobody can find, so both are read and both are kept.
108
+ *
109
+ * @param {Env} [env]
110
+ * @returns {CIEnvironment}
111
+ */
112
+ export function detectCI(env = process.env) {
113
+ if (env.GITHUB_ACTIONS === 'true') return fromGitHub(env);
114
+ if (env.GITLAB_CI === 'true') return fromGitLab(env);
115
+ if (env.CIRCLECI === 'true') return fromCircle(env);
116
+
117
+ const something = env.CI === 'true' || env.CI === '1';
118
+ return {
119
+ on: something,
120
+ provider: 'none',
121
+ name: something ? 'a build server we do not recognise' : 'this machine',
122
+ commit: null,
123
+ branch: null,
124
+ repo: null,
125
+ pullRequest: null,
126
+ beforeSha: null,
127
+ runId: null,
128
+ runUrl: null,
129
+ summaryFile: null,
130
+ note: something
131
+ ? 'Something says this is a build server, but not one whose variables we know how to read. Nothing here can work out what to compare against on its own, so the commit has to be named by hand.'
132
+ : 'This is not a build server, so everything below falls back to what git can work out locally.',
133
+ };
134
+ }
135
+
136
+ /**
137
+ * @param {Env} env
138
+ * @returns {CIEnvironment}
139
+ */
140
+ function fromGitHub(env) {
141
+ const event = readEventFile(env.GITHUB_EVENT_PATH);
142
+ const pr = /** @type {Record<string, any>|null} */ (event?.pull_request ?? null);
143
+ // Three ways to know, and all three are used, because relying on the event file alone
144
+ // would silently stop recognising a pull request the moment that file is unreadable —
145
+ // and the whole strength of a CI run rests on knowing which base to fork from.
146
+ const isPr = pr !== null || (env.GITHUB_EVENT_NAME ?? '').startsWith('pull_request') || Boolean(env.GITHUB_BASE_REF);
147
+
148
+ /** @type {CIPullRequest|null} */
149
+ const pullRequest = isPr
150
+ ? {
151
+ number: text(pr?.number) ?? text(event?.number),
152
+ base: env.GITHUB_BASE_REF || text(pr?.base?.ref),
153
+ baseSha: text(pr?.base?.sha),
154
+ headSha: text(pr?.head?.sha),
155
+ }
156
+ : null;
157
+
158
+ const repo = env.GITHUB_REPOSITORY ?? null;
159
+ const server = env.GITHUB_SERVER_URL ?? 'https://github.com';
160
+ const runId = env.GITHUB_RUN_ID ?? null;
161
+
162
+ return {
163
+ on: true,
164
+ provider: 'github',
165
+ name: 'GitHub Actions',
166
+ // The head of the branch, not the throwaway merge commit, whenever we can tell them apart.
167
+ commit: pullRequest?.headSha ?? env.GITHUB_SHA ?? null,
168
+ branch: env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME || null,
169
+ repo,
170
+ pullRequest,
171
+ beforeSha: usableSha(text(event?.before)),
172
+ runId,
173
+ runUrl: repo && runId ? `${server}/${repo}/actions/runs/${runId}` : null,
174
+ summaryFile: env.GITHUB_STEP_SUMMARY ?? null,
175
+ note: isPr
176
+ ? `GitHub Actions, on pull request ${pullRequest?.number ?? '?'} into ${pullRequest?.base ?? 'the base branch'}.`
177
+ : `GitHub Actions, on a push to ${env.GITHUB_REF_NAME ?? 'a branch'}.`,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * @param {Env} env
183
+ * @returns {CIEnvironment}
184
+ */
185
+ function fromGitLab(env) {
186
+ const isMr = Boolean(env.CI_MERGE_REQUEST_IID);
187
+ return {
188
+ on: true,
189
+ provider: 'gitlab',
190
+ name: 'GitLab CI',
191
+ commit: env.CI_COMMIT_SHA ?? null,
192
+ branch: env.CI_MERGE_REQUEST_SOURCE_BRANCH_NAME || env.CI_COMMIT_REF_NAME || null,
193
+ repo: env.CI_PROJECT_PATH ?? null,
194
+ pullRequest: isMr
195
+ ? {
196
+ number: env.CI_MERGE_REQUEST_IID ?? null,
197
+ base: env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME ?? null,
198
+ // GitLab is the one server that hands over the fork point outright, already worked
199
+ // out, with no fetching. Nothing else in this file gets an answer this cheaply.
200
+ baseSha: usableSha(env.CI_MERGE_REQUEST_DIFF_BASE_SHA ?? null),
201
+ headSha: env.CI_COMMIT_SHA ?? null,
202
+ }
203
+ : null,
204
+ beforeSha: usableSha(env.CI_COMMIT_BEFORE_SHA ?? null),
205
+ runId: env.CI_PIPELINE_ID ?? null,
206
+ runUrl: env.CI_JOB_URL ?? env.CI_PIPELINE_URL ?? null,
207
+ // GitLab has no job summary of its own. The report still gets written to a file and
208
+ // uploaded, it just does not appear on the pipeline page.
209
+ summaryFile: null,
210
+ note: isMr
211
+ ? `GitLab CI, on merge request ${env.CI_MERGE_REQUEST_IID} into ${env.CI_MERGE_REQUEST_TARGET_BRANCH_NAME ?? 'the target branch'}.`
212
+ : `GitLab CI, on a push to ${env.CI_COMMIT_REF_NAME ?? 'a branch'}.`,
213
+ };
214
+ }
215
+
216
+ /**
217
+ * @param {Env} env
218
+ * @returns {CIEnvironment}
219
+ */
220
+ function fromCircle(env) {
221
+ const number = env.CIRCLE_PR_NUMBER ?? lastSegment(env.CIRCLE_PULL_REQUEST);
222
+ const repo = env.CIRCLE_PROJECT_USERNAME && env.CIRCLE_PROJECT_REPONAME ? `${env.CIRCLE_PROJECT_USERNAME}/${env.CIRCLE_PROJECT_REPONAME}` : null;
223
+ return {
224
+ on: true,
225
+ provider: 'circleci',
226
+ name: 'CircleCI',
227
+ commit: env.CIRCLE_SHA1 ?? null,
228
+ branch: env.CIRCLE_BRANCH ?? null,
229
+ repo,
230
+ // CircleCI tells a job the pull request exists and refuses to say what it is aimed at.
231
+ // Recording the number and admitting the base is unknown is the honest shape: the base
232
+ // then has to come from git, and if git cannot supply it the report says so.
233
+ pullRequest: number ? { number, base: null, baseSha: null, headSha: env.CIRCLE_SHA1 ?? null } : null,
234
+ beforeSha: null,
235
+ runId: env.CIRCLE_BUILD_NUM ?? null,
236
+ runUrl: env.CIRCLE_BUILD_URL ?? null,
237
+ summaryFile: null,
238
+ note: number
239
+ ? `CircleCI, on pull request ${number}. CircleCI does not tell a job which branch the request is aimed at, so the base has to be worked out from git.`
240
+ : `CircleCI, on a push to ${env.CIRCLE_BRANCH ?? 'a branch'}.`,
241
+ };
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // The reference
246
+ // ---------------------------------------------------------------------------
247
+
248
+ /**
249
+ * How a build server can get hold of something to compare against, best first.
250
+ *
251
+ * - `named` Somebody said which commit. Nothing beats being told.
252
+ * - `merge-base` The commit this branch forked from. The right answer for a pull
253
+ * request: it isolates what THIS branch did from everything else that
254
+ * landed on the base while it was open.
255
+ * - `released` The commit the project's own reference points at — what its owner
256
+ * last said ship to. The right answer for a push to a main branch.
257
+ * - `last-tag` The most recent tag in this history. Fair, not strong: other people's
258
+ * merges since that tag show up as differences too.
259
+ * - `previous-commit` What the branch was before this push. Narrow: it proves this push,
260
+ * not this branch.
261
+ * - `stored-record` Observations committed into the repository or restored from a cache.
262
+ * No old build is booted at all. Weakest, and it carries a caveat about
263
+ * the machine that took them.
264
+ * - `none` Nothing to compare against. Not a pass, and it must not exit zero.
265
+ *
266
+ * @typedef {'named'|'merge-base'|'released'|'last-tag'|'previous-commit'|'stored-record'|'none'} CIReferenceMode
267
+ */
268
+
269
+ /**
270
+ * How much an answer from this mode is worth. Four words rather than a number, because a
271
+ * number invites somebody to set a threshold on it and there are no thresholds in version 2.
272
+ * @typedef {'strong'|'fair'|'weak'|'none'} CIStrength
273
+ */
274
+
275
+ /**
276
+ * One mode that was thought about, and what happened.
277
+ * @typedef {object} CIConsidered
278
+ * @property {CIReferenceMode} mode
279
+ * @property {boolean} available
280
+ * @property {string} why Why it was used, or why it could not be.
281
+ * @property {string} [unlockedBy] The concrete thing that would make it available.
282
+ */
283
+
284
+ /**
285
+ * What a build server found to compare against, and how much it is worth.
286
+ *
287
+ * @typedef {object} CIReference
288
+ * @property {CIReferenceMode} mode
289
+ * @property {string|null} against Hand this straight to `check({against})`. A commit.
290
+ * @property {boolean} paired Can the old build be booted and walked here.
291
+ * @property {CIStrength} strength
292
+ * @property {string} how Plain English: how this reference was found.
293
+ * @property {string} why Plain English: why this one and not a stronger one.
294
+ * @property {string} [caveat] The warning that belongs on every report of this run.
295
+ * @property {string} [unlockedBy] The concrete thing that would make it stronger.
296
+ * @property {CIConsidered[]} considered
297
+ * @property {boolean} shallow The checkout has no full history, which rules a lot out.
298
+ * @property {'same'|'different'|'unknown'} [machine]
299
+ * Only for a stored record: was it taken on a machine like
300
+ * this one. A record from a different machine reintroduces
301
+ * every difference that comes from the machine being
302
+ * different, which is the thing pairing exists to remove.
303
+ */
304
+
305
+ /**
306
+ * Work out what this build server can compare against.
307
+ *
308
+ * Runs no network calls and writes nothing. A shallow checkout is detected and REPORTED
309
+ * rather than quietly deepened: fetching more history is a thing a workflow file should say
310
+ * it is doing, in the open, not something a library does behind a job's back.
311
+ *
312
+ * @param {{cwd?: string, env?: Env, against?: string, product?: string}} [opts]
313
+ * @returns {Promise<CIReference>}
314
+ */
315
+ export async function referenceForCI(opts = {}) {
316
+ const cwd = path.resolve(opts.cwd ?? process.cwd());
317
+ const env = opts.env ?? process.env;
318
+ const ci = detectCI(env);
319
+ const shallow = (await git(cwd, ['rev-parse', '--is-shallow-repository'])) === 'true';
320
+ const deepen = 'Check out the full history. On GitHub that is `fetch-depth: 0` on actions/checkout; on GitLab it is `GIT_DEPTH: 0`.';
321
+ const headSha = await resolveCommit(cwd, 'HEAD');
322
+
323
+ /**
324
+ * A reference that turns out to BE the build under test is the most dangerous answer this
325
+ * file could give: everything matches, nothing is reported, and the run looks like the
326
+ * strongest possible pass. It is how a shallow clone kills the whole tool silently — clone
327
+ * at depth one and the fork point of every branch comes back as HEAD.
328
+ *
329
+ * So every candidate goes through here, and one that is the same commit is thrown away.
330
+ *
331
+ * @param {string|null} sha
332
+ * @returns {string|null}
333
+ */
334
+ const notThisBuild = (sha) => (sha && headSha && sha === headSha ? null : sha);
335
+ const sameBuild = 'is this exact build. Comparing something against itself proves nothing, so it was not used.';
336
+
337
+ /** @type {CIConsidered[]} */
338
+ const considered = [];
339
+
340
+ /**
341
+ * @param {CIReferenceMode} mode
342
+ * @param {string} why
343
+ * @param {string} [unlockedBy]
344
+ */
345
+ const missed = (mode, why, unlockedBy) => {
346
+ /** @type {CIConsidered} */
347
+ const entry = { mode, available: false, why };
348
+ if (unlockedBy) entry.unlockedBy = unlockedBy;
349
+ considered.push(entry);
350
+ };
351
+
352
+ // ---- named -------------------------------------------------------------
353
+ if (opts.against) {
354
+ const named = await resolveCommit(cwd, opts.against);
355
+ const sha = notThisBuild(named);
356
+ if (named && !sha) {
357
+ missed('named', `You named ${opts.against}, and that ${sameBuild}`, 'Name an earlier commit, tag or release.');
358
+ } else if (sha) {
359
+ considered.push({ mode: 'named', available: true, why: `Somebody named ${opts.against} outright.` });
360
+ return {
361
+ mode: 'named',
362
+ against: sha,
363
+ paired: true,
364
+ strength: 'strong',
365
+ how: `You named ${opts.against}, and it is in this checkout.`,
366
+ why: 'Nothing beats being told which build counts as working.',
367
+ considered,
368
+ shallow,
369
+ };
370
+ }
371
+ else missed('named', `${opts.against} was named, but there is no such commit in this checkout.`, shallow ? deepen : 'Check the tag or commit still exists in this repository.');
372
+ } else {
373
+ missed('named', 'Nobody named a commit to compare against.');
374
+ }
375
+
376
+ const wantsMergeBase = ci.pullRequest !== null;
377
+
378
+ // ---- merge base --------------------------------------------------------
379
+ if (wantsMergeBase) {
380
+ const found = await findMergeBase(cwd, ci);
381
+ if (found && !notThisBuild(found.sha)) {
382
+ // A depth-one clone answers every merge-base question with HEAD. This is the exact
383
+ // shape of the silent green run, and it has to be caught here rather than reported
384
+ // as the strongest mode there is.
385
+ missed(
386
+ 'merge-base',
387
+ shallow
388
+ ? `The fork point came back as this same commit, which ${sameBuild} A clone with no history always answers this way, so nothing has really been worked out.`
389
+ : `This branch has nothing of its own on top of its base yet, so the fork point ${sameBuild}`,
390
+ shallow ? deepen : 'Push a commit to the branch, or aim the check at a different base.',
391
+ );
392
+ } else if (found) {
393
+ considered.push({ mode: 'merge-base', available: true, why: found.how });
394
+ return {
395
+ mode: 'merge-base',
396
+ against: found.sha,
397
+ paired: true,
398
+ strength: 'strong',
399
+ how: found.how,
400
+ why: 'This is the right one for a pull request: it compares what this branch did, and nothing that landed on the base branch while it was open.',
401
+ considered,
402
+ shallow,
403
+ };
404
+ }
405
+ else
406
+ missed(
407
+ 'merge-base',
408
+ shallow
409
+ ? 'This is a pull request, but the checkout has no history, so the commit this branch forked from cannot be worked out.'
410
+ : 'This is a pull request, but the base branch is not in this checkout, so the fork point cannot be worked out.',
411
+ deepen,
412
+ );
413
+ } else {
414
+ missed('merge-base', ci.on ? 'This run is not a pull request, so there is no branch to find a fork point for.' : 'Not on a build server, so there is no pull request to read.');
415
+ }
416
+
417
+ // ---- released ----------------------------------------------------------
418
+ const released = await releasedCommit(cwd, opts.product);
419
+ if (released) {
420
+ const found = await resolveCommit(cwd, released.sha);
421
+ const sha = notThisBuild(found);
422
+ if (found && !sha) {
423
+ missed('released', `The build this project last said ship to ${sameBuild}`, 'Nothing is wrong: you are checking the build you already shipped.');
424
+ } else if (sha) {
425
+ considered.push({ mode: 'released', available: true, why: `The project's reference points at ${short(sha)}, and it is in this checkout.` });
426
+ /** @type {CIReference} */
427
+ const result = {
428
+ mode: 'released',
429
+ against: sha,
430
+ paired: true,
431
+ strength: 'strong',
432
+ how: `The build this project last said ship to: ${short(sha)}${released.note ? ` — ${released.note}` : ''}.`,
433
+ why: 'This is the definition of working for this product, set by a person shipping, not by anything on this build server.',
434
+ considered,
435
+ shallow,
436
+ };
437
+ // On a pull request this is the second choice for a reason worth saying: everything
438
+ // merged since the release counts as a difference too, so the list is longer than the
439
+ // branch is responsible for.
440
+ if (wantsMergeBase) {
441
+ result.strength = 'fair';
442
+ result.caveat =
443
+ 'The fork point of this branch could not be worked out, so this was compared against the last shipped build instead. Anything else that was merged since that release will show up here as well, even though this branch did not do it.';
444
+ result.unlockedBy = deepen;
445
+ }
446
+ return result;
447
+ }
448
+ else missed('released', `The project's reference points at ${short(released.sha)}, and that commit is not in this checkout.`, shallow ? deepen : 'Make sure the commit that was shipped is still in this repository.');
449
+ } else {
450
+ missed('released', 'This project has no build on record as working. Only its owner can set one, by shipping.');
451
+ }
452
+
453
+ // ---- last tag ----------------------------------------------------------
454
+ const tag = await lastTag(cwd);
455
+ if (tag) {
456
+ considered.push({ mode: 'last-tag', available: true, why: `The most recent tag in this history is ${tag.name}.` });
457
+ return {
458
+ mode: 'last-tag',
459
+ against: tag.sha,
460
+ paired: true,
461
+ strength: 'fair',
462
+ how: `The most recent tag in this history: ${tag.name} (${short(tag.sha)}).`,
463
+ why: 'There is no pull request base and nothing on record as working, so the last tag is the nearest thing to a build somebody was happy with.',
464
+ caveat:
465
+ 'A tag is not the same as a build somebody said ship to. Everything merged since that tag shows up here as a difference, whoever wrote it.',
466
+ unlockedBy: 'Record a reference when you release, so this compares against what you actually shipped.',
467
+ considered,
468
+ shallow,
469
+ };
470
+ }
471
+ missed('last-tag', shallow ? 'No tags are in this checkout, and a shallow clone fetches none.' : 'This repository has no tags.', shallow ? deepen : 'Tag your releases.');
472
+
473
+ // ---- previous commit ---------------------------------------------------
474
+ const before = notThisBuild(ci.beforeSha ? await resolveCommit(cwd, ci.beforeSha) : await resolveCommit(cwd, 'HEAD^'));
475
+ if (before) {
476
+ considered.push({ mode: 'previous-commit', available: true, why: `The branch was at ${short(before)} before this push.` });
477
+ return {
478
+ mode: 'previous-commit',
479
+ against: before,
480
+ paired: true,
481
+ strength: 'fair',
482
+ how: `What this branch was immediately before: ${short(before)}.`,
483
+ why: 'Nothing stronger was available, and one commit back is still a real build that can be booted and walked here.',
484
+ caveat:
485
+ 'This proves what this one push did, not what this branch did. A break introduced three pushes ago is in both builds, so it will not appear here at all.',
486
+ unlockedBy: 'Run this on pull requests as well, where the fork point of the whole branch is available.',
487
+ considered,
488
+ shallow,
489
+ };
490
+ }
491
+ missed('previous-commit', 'There is no earlier commit in this checkout.', shallow ? deepen : undefined);
492
+
493
+ // ---- stored record -----------------------------------------------------
494
+ const stored = await storedRecord(cwd, opts.product);
495
+ if (stored) {
496
+ considered.push({ mode: 'stored-record', available: true, why: `There are stored observations for ${stored.buildId}.` });
497
+ const same = stored.machine === 'same';
498
+ return {
499
+ mode: 'stored-record',
500
+ // Null on purpose. The engine finds a stored record through its own reference pointer;
501
+ // handing it a commit it cannot boot would turn a weak answer into a blocked run.
502
+ against: null,
503
+ paired: false,
504
+ strength: same ? 'fair' : 'weak',
505
+ how: `No old build could be put back on this machine, so this used the observations stored for ${stored.buildId}.`,
506
+ why: 'Nothing in this checkout could be booted and walked, so all that is left is the record the old build left the last time it ran.',
507
+ caveat: same
508
+ ? 'No old build was run here. This compares against a record, which cannot catch anything that only shows up when the old build is actually running. The record was taken on a machine like this one, so at least the fonts and the operating system match.'
509
+ : 'No old build was run here, and the stored record was taken on a DIFFERENT machine. Different fonts, a different operating system and different paths all count as differences, so expect noise this run cannot tell apart from a real change.',
510
+ unlockedBy: same ? deepen : 'Take the stored record on a runner like this one — cache the .staysfixed folder from a job on your main branch — or check out the full history so an old build can be booted here instead.',
511
+ considered,
512
+ shallow,
513
+ machine: stored.machine,
514
+ };
515
+ }
516
+ missed('stored-record', 'There are no stored observations in this checkout either.', 'Commit the .staysfixed folder, or restore it from a cache written by a job on your main branch.');
517
+
518
+ // ---- nothing -----------------------------------------------------------
519
+ return {
520
+ mode: 'none',
521
+ against: null,
522
+ paired: false,
523
+ strength: 'none',
524
+ how: 'Nothing was found to compare against.',
525
+ why: 'No named commit, no pull request base, no reference, no tag, no earlier commit and no stored record. There is nothing in this checkout that says what this product used to do.',
526
+ caveat: 'This run proves nothing about your product either way. It is not a pass.',
527
+ unlockedBy: deepen,
528
+ considered,
529
+ shallow,
530
+ };
531
+ }
532
+
533
+ /**
534
+ * The commit this branch forked from.
535
+ *
536
+ * Three ways, in order of how right the answer is. `git merge-base` is the true fork point.
537
+ * The base commit the server hands over is the tip of the base branch at the moment the
538
+ * event fired, which is close but not the same thing — if the base has moved since the
539
+ * branch was cut, it includes work this branch never touched.
540
+ *
541
+ * @param {string} cwd
542
+ * @param {CIEnvironment} ci
543
+ * @returns {Promise<{sha: string, how: string}|null>}
544
+ */
545
+ async function findMergeBase(cwd, ci) {
546
+ const pr = ci.pullRequest;
547
+ // The tip of the branch, and it must be a commit this checkout actually has. A server can
548
+ // name a commit that was never fetched — a shallow clone, or a merge commit built somewhere
549
+ // else — and `git merge-base` against a name git has never heard of fails with no useful
550
+ // message. Falling back to HEAD is right: HEAD is what is about to be walked either way.
551
+ /** @type {string|null} */
552
+ let head = null;
553
+ for (const candidate of [pr?.headSha, ci.commit, 'HEAD']) {
554
+ if (!candidate) continue;
555
+ head = await resolveCommit(cwd, candidate);
556
+ if (head) break;
557
+ }
558
+ if (!head) return null;
559
+
560
+ /** @type {string[]} */
561
+ const bases = [];
562
+ if (pr?.base) bases.push(`origin/${pr.base}`, `refs/remotes/origin/${pr.base}`, pr.base);
563
+ // No base branch was named — CircleCI does this — so try the names a main branch usually has.
564
+ if (bases.length === 0) bases.push('origin/main', 'origin/master', 'origin/HEAD');
565
+
566
+ for (const base of bases) {
567
+ if (!(await resolveCommit(cwd, base))) continue;
568
+ const found = await git(cwd, ['merge-base', base, head]);
569
+ if (found) return { sha: found, how: `The commit this branch forked from ${base}: ${short(found)}.` };
570
+ }
571
+
572
+ if (pr?.baseSha) {
573
+ const sha = await resolveCommit(cwd, pr.baseSha);
574
+ if (sha) {
575
+ const merged = await git(cwd, ['merge-base', sha, head]);
576
+ const use = merged ?? sha;
577
+ return {
578
+ sha: use,
579
+ how: merged
580
+ ? `The commit this branch forked from, worked out from the base ${ci.name} named: ${short(use)}.`
581
+ : `The tip of the base branch when this pull request was opened: ${short(use)}. That is close to the fork point, not exactly it.`,
582
+ };
583
+ }
584
+ }
585
+ return null;
586
+ }
587
+
588
+ /**
589
+ * The commit this project's own reference points at — the build somebody shipped.
590
+ *
591
+ * Read only. Nothing in this file may move that pointer.
592
+ *
593
+ * @param {string} cwd
594
+ * @param {string} [product]
595
+ * @returns {Promise<{sha: string, note: string}|null>}
596
+ */
597
+ async function releasedCommit(cwd, product) {
598
+ try {
599
+ const store = openStore({ root: cwd });
600
+ const name = product ?? (await productName(cwd));
601
+ if (!name) return null;
602
+ const pointer = await referencePointer(store, name);
603
+ if (!pointer) return null;
604
+ const builds = await listBuilds(store, { product: name });
605
+ const hit = builds.find((b) => b.fingerprint.id === pointer.buildId);
606
+ const sha = hit?.fingerprint.gitSha ?? shaFromBuildId(pointer.buildId);
607
+ if (!sha) return null;
608
+ return { sha, note: hit?.fingerprint.version ? `version ${hit.fingerprint.version}` : '' };
609
+ } catch {
610
+ // A store that will not open is a reason to try the next mode, never a reason to fail.
611
+ return null;
612
+ }
613
+ }
614
+
615
+ /**
616
+ * Is there a stored record here at all, and was it taken on a machine like this one.
617
+ *
618
+ * @param {string} cwd
619
+ * @param {string} [product]
620
+ * @returns {Promise<{buildId: string, machine: 'same'|'different'|'unknown'}|null>}
621
+ */
622
+ async function storedRecord(cwd, product) {
623
+ try {
624
+ const store = openStore({ root: cwd });
625
+ const name = product ?? (await productName(cwd));
626
+ if (!name) return null;
627
+ const pointer = await referencePointer(store, name);
628
+ const builds = await listBuilds(store, { product: name });
629
+ if (builds.length === 0) return null;
630
+ const hit = (pointer && builds.find((b) => b.fingerprint.id === pointer.buildId)) || builds[0];
631
+ const here = `${process.platform}-${process.arch}`;
632
+ /** @type {'same'|'different'|'unknown'} */
633
+ const machine = !hit.fingerprint.platform ? 'unknown' : hit.fingerprint.platform === here ? 'same' : 'different';
634
+ return { buildId: hit.fingerprint.id, machine };
635
+ } catch {
636
+ return null;
637
+ }
638
+ }
639
+
640
+ // ---------------------------------------------------------------------------
641
+ // The report
642
+ // ---------------------------------------------------------------------------
643
+
644
+ /**
645
+ * What a build server prints, writes and exits with.
646
+ *
647
+ * @typedef {object} CIReport
648
+ * @property {boolean} ok
649
+ * @property {boolean} blocked The check did not run. Neither a pass nor a failure.
650
+ * @property {number} exitCode 0 nothing changed, 1 something did, 2 no answer at all.
651
+ * @property {string} headline One sentence, the thing a person reads first.
652
+ * @property {string} markdown For a job summary page.
653
+ * @property {string} text For the job log, where markdown is just noise.
654
+ */
655
+
656
+ /**
657
+ * Turn a verdict into the table a build server shows, and the code it exits with.
658
+ *
659
+ * THE EXIT CODES ARE THE POINT OF THE WHOLE FILE, so they are the part to get right:
660
+ *
661
+ * 0 Nothing that already worked has changed. Merge away.
662
+ * 1 Something changed that nobody accounted for. A person or an agent has to look.
663
+ * 2 The check could not run, or there was nothing to compare against. This is NOT a
664
+ * pass. A run that proved nothing exiting zero is the exact failure this tool exists
665
+ * to prevent, and it would be an easy and invisible one to ship.
666
+ *
667
+ * @param {CheckOutcome} verdict
668
+ * @param {{reference?: CIReference, env?: CIEnvironment, durationMs?: number, remembered?: boolean}} [extra]
669
+ * @returns {CIReport}
670
+ */
671
+ export function reportForCI(verdict, extra = {}) {
672
+ const reference = extra.reference;
673
+ const blocked = verdict.blocked === true;
674
+ const nothingToCompare = !verdict.reference || verdict.reference.id === '';
675
+ const sealed = (verdict.findings ?? []).filter((f) => f.sealed);
676
+ const rest = (verdict.findings ?? []).filter((f) => !f.sealed);
677
+ const unstable = verdict.newlyUnstable ?? [];
678
+
679
+ const exitCode = blocked || nothingToCompare ? EXIT.error : verdict.ok ? EXIT.ok : EXIT.failed;
680
+
681
+ const headline = blocked
682
+ ? 'The check could not run, so nothing here says anything about your product either way.'
683
+ : nothingToCompare
684
+ ? 'There was nothing to compare against, so this run proved nothing. It is not a pass.'
685
+ : verdict.ok
686
+ ? 'Nothing that already worked has changed.'
687
+ : sealed.length > 0
688
+ ? `${count(sealed.length, 'thing', 'things')} changed that no agent may wave through, and a person has to look.`
689
+ : `${count(verdict.findings.length, 'thing', 'things')} changed that nobody asked for.`;
690
+
691
+ /** @type {string[]} */
692
+ const md = [];
693
+ md.push('## Stays Fixed');
694
+ md.push('');
695
+ md.push(`**${headline}**`);
696
+ md.push('');
697
+
698
+ // The admission that this run is weaker than usual goes above the table, not below it.
699
+ // Nobody scrolls back up past a green tick to find out it was not worth much.
700
+ const warnings = [reference?.caveat, verdict.modeWarning].filter((w) => typeof w === 'string' && w !== '');
701
+ for (const w of warnings) md.push(`> **Read this first.** ${w}`, '>');
702
+ if (warnings.length > 0) md.push('');
703
+
704
+ md.push('| | |');
705
+ md.push('| --- | --- |');
706
+ if (reference) {
707
+ md.push(row('Compared against', reference.how));
708
+ md.push(row('How that was chosen', reference.why));
709
+ md.push(
710
+ row(
711
+ 'How much it is worth',
712
+ reference.mode === 'none'
713
+ ? 'nothing — there was no old build and no record of one, so this run did not compare anything'
714
+ : `${reference.strength} — ${reference.paired ? 'the old build was put back on this runner and walked again' : 'no old build was run; this is a stored record'}`,
715
+ ),
716
+ );
717
+ } else if (!nothingToCompare) {
718
+ md.push(row('Compared against', nameOfBuild(verdict.reference)));
719
+ }
720
+ if (!blocked && !nothingToCompare) {
721
+ const paths = verdict.coverage?.paths ?? 0;
722
+ const journeys = verdict.coverage?.journeys ?? 0;
723
+ md.push(row('Looked at', `${count(paths, 'address', 'addresses')} across ${count(journeys, 'journey', 'journeys')}`));
724
+ md.push(row('Differences', `${count(verdict.differencesReal ?? 0, 'real one', 'real ones')}, and ${count(verdict.differencesNoise ?? 0, 'thing', 'things')} the product disagrees with itself about anyway`));
725
+ if (unstable.length > 0) md.push(row('Newly unpredictable', `${count(unstable.length, 'address', 'addresses')} that used to give the same answer every time`));
726
+ const waived = verdict.accounted?.waived ?? 0;
727
+ if (waived > 0) md.push(row('Already accounted for', `${count(waived, 'finding was', 'findings were')} dropped because an agent had recorded them as intended before the run`));
728
+ }
729
+ const took = extra.durationMs ?? verdict.durationMs;
730
+ if (typeof took === 'number' && took > 0) md.push(row('Took', minutes(took)));
731
+ if (extra.env?.runUrl) md.push(row('This run', extra.env.runUrl));
732
+ md.push('');
733
+
734
+ if (sealed.length > 0) {
735
+ md.push('### A person has to look at these');
736
+ md.push('');
737
+ for (const f of sealed) md.push(...findingLines(f));
738
+ md.push('');
739
+ }
740
+ if (rest.length > 0) {
741
+ md.push(sealed.length > 0 ? '### And these' : '### What changed that nobody asked for');
742
+ md.push('');
743
+ for (const f of rest) md.push(...findingLines(f));
744
+ md.push('');
745
+ }
746
+ if (unstable.length > 0) {
747
+ md.push('### These used to give the same answer every time, and now they do not');
748
+ md.push('');
749
+ for (const u of unstable.slice(0, 10)) md.push(`- \`${u.path}\` — two runs of this same build disagree about it, and the old build did not`);
750
+ if (unstable.length > 10) md.push(`- and ${unstable.length - 10} more.`);
751
+ md.push('');
752
+ }
753
+
754
+ // Said on a clean run too. A gap that is only mentioned when something fails is a gap
755
+ // nobody ever sees, and quiet that cannot be shown to be earned is worth nothing.
756
+ const gaps = verdict.coverage?.gaps ?? [];
757
+ if (gaps.length > 0) {
758
+ md.push('### What it did not look at');
759
+ md.push('');
760
+ for (const g of gaps.slice(0, 12)) md.push(`- ${g.what} ${g.why}${g.unlockedBy ? ` **${g.unlockedBy}**` : ''}`);
761
+ if (gaps.length > 12) md.push(`- and ${gaps.length - 12} more. All of them are in the evidence attached to this run.`);
762
+ md.push('');
763
+ }
764
+
765
+ if (reference?.unlockedBy) {
766
+ md.push('### What would make this run stronger');
767
+ md.push('');
768
+ md.push(reference.unlockedBy);
769
+ md.push('');
770
+ }
771
+
772
+ md.push('---');
773
+ md.push('');
774
+ md.push(oneLine(verdict.summary ?? ''));
775
+ md.push('');
776
+ // The engine writes its own closing sentence assuming it was allowed to keep what it saw,
777
+ // because on a laptop it always is. On a build server it is not, and a paragraph saying
778
+ // "this run has been kept" when nothing was kept is exactly the quiet untruth this whole
779
+ // tool exists to stop. So it is corrected here, in the open, rather than edited out.
780
+ if (extra.remembered === false) {
781
+ md.push('Nothing from this run was stored. A build server never writes this product\'s record — only a run on the machine that owns the project does that — so anything above about this run being kept for next time does not apply here.');
782
+ md.push('');
783
+ }
784
+ md.push('_Stays Fixed reports. It never approves anything: only a person shipping can say what "working" means._');
785
+ md.push('');
786
+
787
+ const markdown = md.join('\n');
788
+ return { ok: verdict.ok === true && !blocked && !nothingToCompare, blocked, exitCode, headline, markdown, text: plainText(markdown) };
789
+ }
790
+
791
+ /**
792
+ * @param {Finding} f
793
+ * @returns {string[]}
794
+ */
795
+ function findingLines(f) {
796
+ /** @type {string[]} */
797
+ const out = [];
798
+ out.push(`- **${f.sealed ? `[${f.class}] ` : ''}${f.title}**`);
799
+ const example = f.differences?.[0];
800
+ if (example) {
801
+ const where = `\`${example.path}\``;
802
+ if (example.kind === 'appeared') out.push(` - ${where}: was not there before, and now it is ${show(example.candidate)}`);
803
+ else if (example.kind === 'vanished') out.push(` - ${where}: was ${show(example.reference)}, and now it is not there at all`);
804
+ else out.push(` - ${where}: was ${show(example.reference)}, now ${show(example.candidate)}`);
805
+ }
806
+ const n = f.count ?? f.differences?.length ?? 0;
807
+ if (n > 1) out.push(` - the same thing in ${n} places`);
808
+ if (f.why) out.push(` - ${f.why}`);
809
+ return out;
810
+ }
811
+
812
+ // ---------------------------------------------------------------------------
813
+ // Writing it down
814
+ // ---------------------------------------------------------------------------
815
+
816
+ /**
817
+ * Append the report to the job summary, when the build server has one.
818
+ *
819
+ * Returns the file it wrote to, or null when there is nowhere to write. Null is a normal
820
+ * answer on GitLab and CircleCI, neither of which has a summary page.
821
+ *
822
+ * @param {CIReport} report
823
+ * @param {CIEnvironment} [env]
824
+ * @returns {Promise<string|null>}
825
+ */
826
+ export async function writeJobSummary(report, env) {
827
+ const where = (env ?? detectCI()).summaryFile;
828
+ if (!where) return null;
829
+ try {
830
+ await fsp.appendFile(where, `${report.markdown}\n`);
831
+ return where;
832
+ } catch {
833
+ // A summary page is a nicety. Losing it must never cost the exit code, which is the
834
+ // half that actually stops the merge.
835
+ return null;
836
+ }
837
+ }
838
+
839
+ /**
840
+ * Everything worth keeping from this run, in one folder a workflow can upload.
841
+ *
842
+ * WHAT IS IN HERE, HONESTLY: the verdict, the reference decision, the summary, and the
843
+ * store — which holds every observation both builds produced, as JSONL. That is the real
844
+ * evidence and it is enough to work out what happened after the runner is gone.
845
+ *
846
+ * WHAT IS NOT IN HERE: pictures. The engine writes its evidence images into a scratch
847
+ * folder and deletes that folder when the run ends, so by the time this is called they no
848
+ * longer exist. Saying so is better than shipping an empty folder called evidence.
849
+ *
850
+ * @param {{cwd?: string, dir?: string, verdict: CheckOutcome, reference: CIReference, report: CIReport, env?: CIEnvironment}} what
851
+ * @returns {Promise<string>}
852
+ */
853
+ export async function saveEvidence(what) {
854
+ const cwd = path.resolve(what.cwd ?? process.cwd());
855
+ const dir = path.resolve(cwd, what.dir ?? path.join('.staysfixed', 'ci'));
856
+ await fsp.mkdir(dir, { recursive: true });
857
+ await fsp.writeFile(path.join(dir, 'verdict.json'), `${JSON.stringify(what.verdict, null, 2)}\n`);
858
+ await fsp.writeFile(path.join(dir, 'reference.json'), `${JSON.stringify(what.reference, null, 2)}\n`);
859
+ await fsp.writeFile(path.join(dir, 'summary.md'), `${what.report.markdown}\n`);
860
+ if (what.env) await fsp.writeFile(path.join(dir, 'where-it-ran.json'), `${JSON.stringify(what.env, null, 2)}\n`);
861
+ await fsp.writeFile(
862
+ path.join(dir, 'README.md'),
863
+ [
864
+ '# What is in here',
865
+ '',
866
+ '- `summary.md` — the same report that was written into the job summary.',
867
+ '- `verdict.json` — everything the check concluded, including every gap in what it looked at.',
868
+ '- `reference.json` — what it compared against, how that was chosen, and what would have made it stronger.',
869
+ '- `where-it-ran.json` — the commit, the branch and the pull request this was.',
870
+ '',
871
+ 'There are no pictures in here. The engine writes its evidence images into a scratch',
872
+ 'folder and clears that folder when the run ends, so nothing is left to collect by the',
873
+ 'time the job packs its results up. The observations in the store are the evidence.',
874
+ '',
875
+ ].join('\n'),
876
+ );
877
+ return dir;
878
+ }
879
+
880
+ // ---------------------------------------------------------------------------
881
+ // The whole thing
882
+ // ---------------------------------------------------------------------------
883
+
884
+ /**
885
+ * Work out the reference, run the check, write the report, hand back an exit code.
886
+ *
887
+ * This is what a workflow file calls, and it is one function on purpose: everything a
888
+ * build server needs to do is here, in this order, with no way to accidentally leave the
889
+ * exit code behind.
890
+ *
891
+ * `staysfixed ci` is not a command yet because the command table belongs to another file.
892
+ * When it becomes one, it is this function and nothing else.
893
+ *
894
+ * @param {{cwd?: string, env?: Env, against?: string, paired?: boolean, journeys?: string, only?: string[], product?: string, evidenceDir?: string, quiet?: boolean, remember?: boolean}} [opts]
895
+ * @returns {Promise<{exitCode: number, report: CIReport, reference: CIReference, verdict: CheckOutcome, evidence: string|null}>}
896
+ */
897
+ export async function runCI(opts = {}) {
898
+ const cwd = path.resolve(opts.cwd ?? process.cwd());
899
+ const env = opts.env ?? process.env;
900
+ const where = detectCI(env);
901
+ const reference = await referenceForCI({ cwd, env, against: opts.against, product: opts.product });
902
+
903
+ const started = Date.now();
904
+ /** @type {CheckOutcome} */
905
+ const verdict = await check({
906
+ cwd,
907
+ product: opts.product,
908
+ // Null means "use whatever the project's own reference points at", which for the
909
+ // stored-record mode is exactly right and for every other mode never happens.
910
+ against: reference.against ?? undefined,
911
+ // A build server has a whole machine to itself and nothing else to do with it. Where the
912
+ // old build can be booted, boot it: the expensive answer is the only one worth blocking
913
+ // a merge on, and this is the one place where paying for it costs nobody any waiting.
914
+ paired: opts.paired ?? reference.paired,
915
+ journeys: opts.journeys,
916
+ only: opts.only,
917
+ // A pull request job must never write this product's record. Its observations were taken
918
+ // on a machine nobody will see again, off a branch nobody has merged, and letting them
919
+ // become "what the old build did" would move the standard sideways every time a runner
920
+ // changed. So this is off unless a job asks for it in so many words.
921
+ //
922
+ // The one job that should ask is a job on your main branch whose purpose is to LEAVE a
923
+ // record for later pull requests to compare against — see the caching section in
924
+ // docs/running-it-in-ci.md. That is the only shape where remembering is right, and it is
925
+ // a deliberate flag rather than a default because getting it the wrong way round turns
926
+ // every red pull request into the new definition of working.
927
+ remember: opts.remember === true,
928
+ });
929
+
930
+ const report = reportForCI(verdict, { reference, env: where, durationMs: Date.now() - started, remembered: opts.remember === true });
931
+
932
+ /** @type {string|null} */
933
+ let evidence = null;
934
+ try {
935
+ evidence = await saveEvidence({ cwd, dir: opts.evidenceDir, verdict, reference, report, env: where });
936
+ } catch {
937
+ // Losing the attachment must never change the answer.
938
+ }
939
+ await writeJobSummary(report, where);
940
+
941
+ if (opts.quiet !== true) process.stdout.write(`${report.text}\n`);
942
+ return { exitCode: report.exitCode, report, reference, verdict, evidence };
943
+ }
944
+
945
+ // ---------------------------------------------------------------------------
946
+ // Small things
947
+ // ---------------------------------------------------------------------------
948
+
949
+ /**
950
+ * @param {string} cwd
951
+ * @param {string[]} args
952
+ * @returns {Promise<string|null>}
953
+ */
954
+ async function git(cwd, args) {
955
+ try {
956
+ const { stdout } = await exec('git', args, { cwd, timeout: 20_000, maxBuffer: 8 * 1024 * 1024 });
957
+ const out = stdout.trim();
958
+ return out === '' ? null : out;
959
+ } catch {
960
+ return null;
961
+ }
962
+ }
963
+
964
+ /**
965
+ * A name a person typed, turned into the commit it means — or null if this checkout has never
966
+ * heard of it.
967
+ * @param {string} cwd
968
+ * @param {string} name
969
+ * @returns {Promise<string|null>}
970
+ */
971
+ async function resolveCommit(cwd, name) {
972
+ return await git(cwd, ['rev-parse', '--verify', '--quiet', `${name}^{commit}`]);
973
+ }
974
+
975
+ /**
976
+ * The most recent tag in this history, and what it points at.
977
+ * @param {string} cwd
978
+ * @returns {Promise<{name: string, sha: string}|null>}
979
+ */
980
+ async function lastTag(cwd) {
981
+ // HEAD itself being tagged is the release-just-happened case, and comparing a build
982
+ // against itself proves nothing, so step back one commit before asking.
983
+ for (const from of ['HEAD^', 'HEAD']) {
984
+ const name = await git(cwd, ['describe', '--tags', '--abbrev=0', from]);
985
+ if (!name) continue;
986
+ const sha = await resolveCommit(cwd, name);
987
+ if (!sha) continue;
988
+ const head = await resolveCommit(cwd, 'HEAD');
989
+ if (sha === head) continue;
990
+ return { name, sha };
991
+ }
992
+ return null;
993
+ }
994
+
995
+ /**
996
+ * @param {string} cwd
997
+ * @returns {Promise<string|null>}
998
+ */
999
+ async function productName(cwd) {
1000
+ try {
1001
+ const pkg = JSON.parse(await fsp.readFile(path.join(cwd, 'package.json'), 'utf8'));
1002
+ return typeof pkg?.name === 'string' ? pkg.name : path.basename(cwd);
1003
+ } catch {
1004
+ return path.basename(cwd);
1005
+ }
1006
+ }
1007
+
1008
+ /**
1009
+ * Build ids made from a commit look like `git-a1b2c3d4e5f6`. Getting the commit back out of
1010
+ * one is how a reference set on another machine still names something this checkout has.
1011
+ * @param {string} buildId
1012
+ * @returns {string|null}
1013
+ */
1014
+ function shaFromBuildId(buildId) {
1015
+ const m = /^git-([0-9a-f]{7,40})$/.exec(buildId ?? '');
1016
+ return m ? m[1] : null;
1017
+ }
1018
+
1019
+ /**
1020
+ * The file GitHub drops the whole event into. It is the only place the tip of the branch and
1021
+ * the base commit can be read from without asking the network.
1022
+ *
1023
+ * Read synchronously on purpose: `detectCI` is called from places that are not async, it
1024
+ * happens once, and it is a few kilobytes on a local disk.
1025
+ *
1026
+ * @param {string|undefined} file
1027
+ * @returns {Record<string, any>|null}
1028
+ */
1029
+ function readEventFile(file) {
1030
+ if (!file) return null;
1031
+ try {
1032
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
1033
+ } catch {
1034
+ // No event file, an unreadable one, or one this server writes in some other shape. Every
1035
+ // caller below already copes with not knowing, so this is a shrug and not a failure.
1036
+ return null;
1037
+ }
1038
+ }
1039
+
1040
+ /**
1041
+ * A commit that is all zeros means "there was nothing here before" — the first push to a new
1042
+ * branch. It is a real value in the environment and a useless one to compare against.
1043
+ * @param {string|null} sha
1044
+ * @returns {string|null}
1045
+ */
1046
+ function usableSha(sha) {
1047
+ if (!sha) return null;
1048
+ return /^0+$/.test(sha) ? null : sha;
1049
+ }
1050
+
1051
+ /**
1052
+ * @param {unknown} v
1053
+ * @returns {string|null}
1054
+ */
1055
+ function text(v) {
1056
+ if (v === null || v === undefined) return null;
1057
+ const s = String(v);
1058
+ return s === '' ? null : s;
1059
+ }
1060
+
1061
+ /**
1062
+ * @param {string|undefined} url
1063
+ * @returns {string|null}
1064
+ */
1065
+ function lastSegment(url) {
1066
+ if (!url) return null;
1067
+ const parts = url.split('/').filter((p) => p !== '');
1068
+ return parts.length > 0 ? parts[parts.length - 1] : null;
1069
+ }
1070
+
1071
+ /** @param {string} sha */
1072
+ function short(sha) {
1073
+ return sha.slice(0, 7);
1074
+ }
1075
+
1076
+ /**
1077
+ * @param {number} n
1078
+ * @param {string} one
1079
+ * @param {string} many
1080
+ */
1081
+ function count(n, one, many) {
1082
+ return `${n} ${n === 1 ? one : many}`;
1083
+ }
1084
+
1085
+ /**
1086
+ * Time as a plain total, never a clock range.
1087
+ * @param {number} ms
1088
+ * @returns {string}
1089
+ */
1090
+ function minutes(ms) {
1091
+ const secs = Math.round(ms / 1000);
1092
+ if (secs < 90) return `about ${count(secs, 'second', 'seconds')}`;
1093
+ const mins = Math.round(secs / 60);
1094
+ return `about ${count(mins, 'minute', 'minutes')}`;
1095
+ }
1096
+
1097
+ /**
1098
+ * @param {string} label
1099
+ * @param {string} value
1100
+ */
1101
+ function row(label, value) {
1102
+ return `| ${label} | ${value.split('|').join('\\|')} |`;
1103
+ }
1104
+
1105
+ /**
1106
+ * @param {unknown} value
1107
+ * @returns {string}
1108
+ */
1109
+ function show(value) {
1110
+ const t = typeof value === 'string' ? value : (JSON.stringify(value) ?? String(value));
1111
+ // A real newline inside a markdown list item ends the item, so the rest of the value
1112
+ // silently falls out of the report. Writing them the way code writes them keeps a
1113
+ // difference like "ready" becoming "READY" on one readable line.
1114
+ const flat = t.split('\r\n').join('\\n').split('\n').join('\\n').split('\t').join('\\t');
1115
+ const trimmed = flat.length > 80 ? `${flat.slice(0, 77)}…` : flat;
1116
+ return `\`${trimmed.split('`').join("'")}\``;
1117
+ }
1118
+
1119
+ /**
1120
+ * @param {import('./types.js').BuildFingerprint|undefined} build
1121
+ * @returns {string}
1122
+ */
1123
+ function nameOfBuild(build) {
1124
+ if (!build) return 'the build with no name';
1125
+ if (build.version) return build.version;
1126
+ if (build.gitSha) return build.gitSha.slice(0, 7);
1127
+ return build.id || 'the build with no name';
1128
+ }
1129
+
1130
+ /**
1131
+ * @param {string} s
1132
+ * @returns {string}
1133
+ */
1134
+ function oneLine(s) {
1135
+ return s.split(/\s+/).join(' ').trim();
1136
+ }
1137
+
1138
+ /**
1139
+ * The same report, with the markdown taken off, for a job log where markdown is just noise.
1140
+ * @param {string} markdown
1141
+ * @returns {string}
1142
+ */
1143
+ function plainText(markdown) {
1144
+ return markdown
1145
+ .split('\n')
1146
+ .filter((line) => !/^\|\s*-+\s*\|/.test(line))
1147
+ .map((line) => {
1148
+ if (/^\|/.test(line)) {
1149
+ const cells = line.split('|').slice(1, -1).map((c) => c.trim());
1150
+ return cells.filter((c) => c !== '').join(': ');
1151
+ }
1152
+ return line
1153
+ .replace(/^#+\s*/, '')
1154
+ .replace(/^>\s?/, '')
1155
+ .split('**').join('')
1156
+ .split('`').join('')
1157
+ .replace(/^_(.*)_$/, '$1');
1158
+ })
1159
+ .join('\n')
1160
+ .replace(/\n{3,}/g, '\n\n');
1161
+ }
1162
+
1163
+ // ---------------------------------------------------------------------------
1164
+ // Running this file directly
1165
+ // ---------------------------------------------------------------------------
1166
+
1167
+ /**
1168
+ * `node src/v2/ci.js` runs the whole thing and exits with the code that stops the merge.
1169
+ *
1170
+ * It is here rather than in the command table because that table lives in another file. A
1171
+ * workflow can call this path directly today and switch to `staysfixed ci` the day it exists,
1172
+ * with no change to what happens.
1173
+ *
1174
+ * @param {string[]} argv
1175
+ * @returns {Promise<number>}
1176
+ */
1177
+ export async function main(argv) {
1178
+ /** @type {Record<string, string|boolean>} */
1179
+ const flags = {};
1180
+ for (let i = 0; i < argv.length; i += 1) {
1181
+ const arg = argv[i];
1182
+ if (!arg.startsWith('--')) continue;
1183
+ const [name, inline] = arg.slice(2).split('=');
1184
+ if (inline !== undefined) flags[name] = inline;
1185
+ else if (argv[i + 1] && !argv[i + 1].startsWith('--')) flags[name] = argv[(i += 1)];
1186
+ else flags[name] = true;
1187
+ }
1188
+ try {
1189
+ const result = await runCI({
1190
+ cwd: typeof flags.cwd === 'string' ? flags.cwd : undefined,
1191
+ against: typeof flags.against === 'string' ? flags.against : undefined,
1192
+ journeys: typeof flags.journeys === 'string' ? flags.journeys : undefined,
1193
+ product: typeof flags.product === 'string' ? flags.product : undefined,
1194
+ evidenceDir: typeof flags.evidence === 'string' ? flags.evidence : undefined,
1195
+ paired: flags.paired === true ? true : undefined,
1196
+ remember: flags.remember === true,
1197
+ });
1198
+ return result.exitCode;
1199
+ } catch (e) {
1200
+ // Anything that reaches here never ran a check, so it is exit 2. Never 1, which a reader
1201
+ // would take to mean the product changed, and never 0.
1202
+ process.stderr.write(`Stays Fixed could not run on this build server. ${messageOf(e)}\n`);
1203
+ return EXIT.error;
1204
+ }
1205
+ }
1206
+
1207
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
1208
+ process.exitCode = await main(process.argv.slice(2));
1209
+ }