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.
- package/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
- package/src/watch/panel.js +73 -44
package/src/v2/cli.js
ADDED
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The command line for version 2 — the difference engine.
|
|
3
|
+
*
|
|
4
|
+
* `staysfixed check` stays the front door, because it is the thing everybody
|
|
5
|
+
* already types, but it now means something bigger: run the build you have
|
|
6
|
+
* against the build you were happy with, and report only what changed that
|
|
7
|
+
* nobody asked for.
|
|
8
|
+
*
|
|
9
|
+
* NOTHING THAT WORKED THIS MORNING MAY BREAK. Somebody installed this yesterday
|
|
10
|
+
* and has `staysfixed check --guards` in a git hook. So the version 1 commands
|
|
11
|
+
* are not removed, not renamed and not deprecated with a warning: the same flags
|
|
12
|
+
* they always typed still reach the same code. `--pictures`, `--guards` and
|
|
13
|
+
* `--watch` are the version 1 check, exactly as before. `check` with none of
|
|
14
|
+
* them is the difference engine. That is the whole migration.
|
|
15
|
+
*
|
|
16
|
+
* This module deliberately holds no engine logic. It parses, it delegates, and
|
|
17
|
+
* it says what came back in plain English — which is the one job that has to
|
|
18
|
+
* sound the same whether a person or an agent is reading it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { StaysFixedError, EXIT, messageOf } from '../core/errors.js';
|
|
22
|
+
import { say, ok, warn, fail, blank, heading, paint, duration, setLogLevel } from '../core/log.js';
|
|
23
|
+
import { openStore } from './store.js';
|
|
24
|
+
import { SHIP_COMMANDS } from './ship.js';
|
|
25
|
+
import { escalationBlock, escalationsFor, productFor, writeEscalations } from './escalate.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What comes back from a check. Everything that did not change never appears
|
|
29
|
+
* here at all, which is the entire point of the tool. The full shape is
|
|
30
|
+
* `Verdict` in src/v2/run.js; these are the parts the command line reads.
|
|
31
|
+
*
|
|
32
|
+
* @typedef {import('./types.js').Verdict} Verdict
|
|
33
|
+
* @typedef {import('./types.js').Finding} Finding
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* How many lines any one list in the report is allowed to run to. Past this it
|
|
38
|
+
* stops being something a person reads and starts being something they skim,
|
|
39
|
+
* and the rest is one count away in --json.
|
|
40
|
+
*/
|
|
41
|
+
const MOST_LINES = 6;
|
|
42
|
+
|
|
43
|
+
/** The flags the difference engine adds. Declared once so help and parsing agree. */
|
|
44
|
+
const V2_SPEC = {
|
|
45
|
+
booleans: ['paired', 'selfcheck', 'json'],
|
|
46
|
+
strings: ['against', 'journeys', 'escalations', 'surface', 'at'],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** @type {[string, string][]} */
|
|
50
|
+
const V2_OPTIONS = [
|
|
51
|
+
['--against <ref>', 'Compare against this marker, tag or commit instead of the newest reference.'],
|
|
52
|
+
['--paired', 'Boot the old build live from the start instead of trusting the stored record. Slower, and the strongest answer there is.'],
|
|
53
|
+
['--journeys <source>', 'Where the steps come from: suite, code, recorded, or a path to a journeys file.'],
|
|
54
|
+
['--surface <kind>', 'Check only one kind of product: cli, library, server, web, electron, android, ios or windows. Nothing else is walked, and a run that cannot reach it says so instead of quietly checking something else.'],
|
|
55
|
+
['--at <where>', 'Where that product is: a URL for the web, the built app for a desktop, the APK or the .app for a phone.'],
|
|
56
|
+
['--selfcheck', 'Run the deliberately broken builds and prove the engine still catches them.'],
|
|
57
|
+
['--json', 'Print the whole result as JSON and nothing else. This is what an agent reads.'],
|
|
58
|
+
['--escalations <file>', 'Write the handful of things a person has to rule on into a file, in plain English, ready to paste into a closing summary.'],
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
/** The version 1 flags, kept working word for word. */
|
|
62
|
+
const V1_SPEC = {
|
|
63
|
+
booleans: ['guards', 'pictures', 'record', 'report', 'watch', 'watch-front', 'keep-open', 'profile'],
|
|
64
|
+
strings: ['watch-side', 'watch-width'],
|
|
65
|
+
arrays: ['only'],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** @type {[string, string][]} */
|
|
69
|
+
const V1_OPTIONS = [
|
|
70
|
+
['--pictures', 'The version 1 picture check, unchanged.'],
|
|
71
|
+
['--guards', 'The version 1 guards, unchanged.'],
|
|
72
|
+
['--only <name>', 'Just this journey, screen or guard. Repeat it for several.'],
|
|
73
|
+
['--watch', 'Open the version 1 panel beside your app and watch it happen.'],
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The commands version 2 replaces, in exactly the shape `src/cli/index.js`
|
|
78
|
+
* already uses for its own. Merging this over the existing table is the whole
|
|
79
|
+
* of the wiring:
|
|
80
|
+
*
|
|
81
|
+
* import { V2_COMMANDS } from '../v2/cli.js';
|
|
82
|
+
* Object.assign(COMMANDS, V2_COMMANDS);
|
|
83
|
+
*
|
|
84
|
+
* @type {Record<string, {summary: string, usage: string, describe: string, options: [string,string][], examples: string[], spec: {booleans?: string[], strings?: string[], arrays?: string[]}, load: () => Promise<{run: (ctx: any) => Promise<number>}>}>}
|
|
85
|
+
*/
|
|
86
|
+
export const V2_COMMANDS = {
|
|
87
|
+
// `staysfixed ship` comes from src/v2/ship.js. It is merged in here rather than into the
|
|
88
|
+
// version 1 command table so that wiring version 2 into the front door stays the one
|
|
89
|
+
// import it has always been.
|
|
90
|
+
//
|
|
91
|
+
// `staysfixed init` from src/v2/init.js is deliberately NOT merged in yet, and this is
|
|
92
|
+
// the same call as the MCP server: version 2's init is a BREAKING change. Version 1's
|
|
93
|
+
// init writes settings for any folder; version 2's reads the project first and writes
|
|
94
|
+
// nothing when it cannot tell what the project is, which is better and is not what the
|
|
95
|
+
// three tests in test/cli.test.js describe. Somebody installed this last week and has
|
|
96
|
+
// `staysfixed init` in a setup script. Adding one line here —
|
|
97
|
+
//
|
|
98
|
+
// import { INIT_COMMANDS } from './init.js'; ...INIT_COMMANDS,
|
|
99
|
+
//
|
|
100
|
+
// switches it over, and those three tests have to be rewritten in the same change to
|
|
101
|
+
// say what the new one does. That is a decision, not an oversight, and it belongs in a
|
|
102
|
+
// change of its own rather than arriving as a side effect of wiring up the phones.
|
|
103
|
+
...SHIP_COMMANDS,
|
|
104
|
+
|
|
105
|
+
check: {
|
|
106
|
+
summary: 'Prove nothing that already worked has changed. This is the one you run.',
|
|
107
|
+
usage: 'staysfixed check [--against <ref>] [--paired] [--journeys <source>] [--json]',
|
|
108
|
+
describe:
|
|
109
|
+
'Runs your product through the same steps twice, compares it against the build you\nwere last happy with, subtracts anything the product disagrees with itself about,\nand reports only the differences that are left. Nothing that was already the same\nis mentioned at all — that is the point, and it is what keeps the answer short\nenough for an agent to read every word of it.\n\nSaying what you meant to change, and marking a difference as intended, are not\ndone from here. They need the files you expect to touch, and they are checked\nand counted, so they live where an agent works: the staysfixed_intent and\nstaysfixed_waive tools on the MCP server.\n\nThe version 1 picture check is still here: add --pictures, --guards or --watch\nand nothing about your old command changes.',
|
|
110
|
+
options: [...V2_OPTIONS, ...V1_OPTIONS],
|
|
111
|
+
examples: [
|
|
112
|
+
'staysfixed check',
|
|
113
|
+
'staysfixed check --json',
|
|
114
|
+
'staysfixed check --against v0.13.0',
|
|
115
|
+
'staysfixed check --paired',
|
|
116
|
+
'staysfixed check --surface web --at http://localhost:3000',
|
|
117
|
+
'staysfixed check --selfcheck',
|
|
118
|
+
'staysfixed check --pictures # exactly what version 1 did',
|
|
119
|
+
],
|
|
120
|
+
spec: {
|
|
121
|
+
booleans: [...V2_SPEC.booleans, ...V1_SPEC.booleans],
|
|
122
|
+
strings: [...V2_SPEC.strings, ...V1_SPEC.strings],
|
|
123
|
+
arrays: [...V1_SPEC.arrays],
|
|
124
|
+
},
|
|
125
|
+
load: async () => ({ run }),
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
doctor: {
|
|
129
|
+
summary: 'What this tool can and cannot check on this machine, and what would unlock more.',
|
|
130
|
+
usage: 'staysfixed doctor [--json] [--offline] [--fix]',
|
|
131
|
+
describe:
|
|
132
|
+
'Looks at this machine rather than at your project: what is installed, which other\nmachines it can already reach, what each of those lets it watch, and what exactly\nis in the way of the rest. It never asks you to set up something that already\nworks — everything it lists as missing failed a real check first.\n\n--json is the same answer as an object, and it is the first thing an agent\nshould call. --fix repairs the small things version 1 could repair.',
|
|
133
|
+
options: [
|
|
134
|
+
['--json', 'The whole answer as one JSON object. For agents.'],
|
|
135
|
+
['--offline', 'Do not dial any other machine. Faster, and reports no runners.'],
|
|
136
|
+
['--fix', 'Repair the small local things that can be repaired safely.'],
|
|
137
|
+
],
|
|
138
|
+
examples: ['staysfixed doctor', 'staysfixed doctor --json'],
|
|
139
|
+
spec: { booleans: ['json', 'offline', 'fix'] },
|
|
140
|
+
load: async () => ({ run: doctorRun }),
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `staysfixed doctor`. `--fix` is version 1's repair pass, which is still the
|
|
146
|
+
* only thing in the tool that changes a file on disk without being asked twice.
|
|
147
|
+
*
|
|
148
|
+
* @param {import('../cli/index.js').CliContext} ctx
|
|
149
|
+
* @returns {Promise<number>}
|
|
150
|
+
*/
|
|
151
|
+
export async function doctorRun(ctx) {
|
|
152
|
+
if (ctx.bool('fix')) {
|
|
153
|
+
const v1 = await import('../cli/doctor.js');
|
|
154
|
+
return await v1.run(ctx);
|
|
155
|
+
}
|
|
156
|
+
const v2 = await import('./doctor.js');
|
|
157
|
+
return await v2.run(ctx);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* `staysfixed check`.
|
|
162
|
+
*
|
|
163
|
+
* @param {import('../cli/index.js').CliContext} ctx
|
|
164
|
+
* @returns {Promise<number>}
|
|
165
|
+
*/
|
|
166
|
+
export async function run(ctx) {
|
|
167
|
+
// Version 1 first. Anybody whose command line names pictures, guards or the
|
|
168
|
+
// watch panel gets exactly the run they got yesterday, byte for byte.
|
|
169
|
+
if (wantsVersionOne(ctx)) {
|
|
170
|
+
const v1 = await import('../cli/check.js');
|
|
171
|
+
return await v1.run(ctx);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --json means the answer belongs to a machine. Every line meant for a person
|
|
175
|
+
// is switched off before anything else runs, rather than trusted not to
|
|
176
|
+
// print: one stray sentence on standard output and the JSON will not parse.
|
|
177
|
+
// Warnings and errors still go to standard error, where they cannot corrupt
|
|
178
|
+
// it.
|
|
179
|
+
const asJson = ctx.bool('json');
|
|
180
|
+
if (asJson) setLogLevel({ quiet: true });
|
|
181
|
+
|
|
182
|
+
if (ctx.bool('selfcheck')) return await runSelfCheck(ctx, asJson);
|
|
183
|
+
|
|
184
|
+
const check = await engineCheck();
|
|
185
|
+
/** @type {Verdict} */
|
|
186
|
+
const verdict = await check(checkOptions(ctx));
|
|
187
|
+
|
|
188
|
+
// Write down what this check concluded, before printing anything.
|
|
189
|
+
//
|
|
190
|
+
// `shouldCut` refuses to make a build the reference unless that build was
|
|
191
|
+
// actually checked — which is the whole safeguard against a broken build
|
|
192
|
+
// quietly becoming the definition of working. Without this line a person who
|
|
193
|
+
// checks on the command line and then ships is told their build was "never
|
|
194
|
+
// checked", and the safeguard fires on the honest case instead of the careless
|
|
195
|
+
// one. The agent surface records its own; this is the command line's half.
|
|
196
|
+
try {
|
|
197
|
+
const { recordCheck } = await import('./reference.js');
|
|
198
|
+
const { openStore } = await import('./store.js');
|
|
199
|
+
await recordCheck(openStore({ root: ctx.cwd ?? process.cwd() }), {
|
|
200
|
+
buildId: verdict.candidate?.id,
|
|
201
|
+
product: verdict.product,
|
|
202
|
+
ok: verdict.ok,
|
|
203
|
+
blocked: /** @type {any} */ (verdict).blocked === true,
|
|
204
|
+
findings: verdict.findings.length,
|
|
205
|
+
by: 'staysfixed check',
|
|
206
|
+
});
|
|
207
|
+
} catch {
|
|
208
|
+
// Never let bookkeeping cost somebody the result they came for.
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (asJson) {
|
|
212
|
+
process.stdout.write(JSON.stringify(verdict) + '\n');
|
|
213
|
+
} else {
|
|
214
|
+
report(verdict);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
await sayWhatNeedsAPerson(ctx, verdict, asJson);
|
|
218
|
+
|
|
219
|
+
// A run with nothing on record to compare against has not proved your product
|
|
220
|
+
// is fine — it has proved nothing at all, and it must not exit 0 and let a
|
|
221
|
+
// release through on the strength of it.
|
|
222
|
+
if (nothingToCompare(verdict)) return EXIT.error;
|
|
223
|
+
return verdict.ok ? EXIT.ok : EXIT.failed;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The handful of things a person has to rule on, and nothing else.
|
|
228
|
+
*
|
|
229
|
+
* This is the whole of what version 2 asks of him. Everything else — what is different,
|
|
230
|
+
* whether it is noise, whether the agent caused it — is answered by the machine or by the
|
|
231
|
+
* agent. What lands here is the small class no agent may wave through, and it is written to
|
|
232
|
+
* be pasted straight into a closing summary rather than read from a screen.
|
|
233
|
+
*
|
|
234
|
+
* @param {import('../cli/index.js').CliContext} ctx
|
|
235
|
+
* @param {Verdict} verdict
|
|
236
|
+
* @param {boolean} asJson
|
|
237
|
+
* @returns {Promise<void>}
|
|
238
|
+
*/
|
|
239
|
+
async function sayWhatNeedsAPerson(ctx, verdict, asJson) {
|
|
240
|
+
const store = openStore({ root: ctx.cwd });
|
|
241
|
+
const product = verdict.product || (await productFor(ctx.cwd));
|
|
242
|
+
const escalations = await escalationsFor(store, product).catch(() => null);
|
|
243
|
+
if (!escalations) return;
|
|
244
|
+
|
|
245
|
+
const file = ctx.str('escalations');
|
|
246
|
+
if (file) {
|
|
247
|
+
const written = await writeEscalations(store, product, file);
|
|
248
|
+
if (!asJson) {
|
|
249
|
+
say(paint.grey(` ${written.count === 0 ? 'Nothing needs a person' : `${written.count} thing${written.count === 1 ? '' : 's'} for a person`} — written to ${written.file}`));
|
|
250
|
+
blank();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (asJson || escalations.items.length === 0) return;
|
|
255
|
+
heading('Put this in your summary');
|
|
256
|
+
blank();
|
|
257
|
+
for (const line of escalationBlock(escalations).split('\n')) say(line === '' ? '' : ` ${line}`);
|
|
258
|
+
blank();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Was there anything to compare this build against at all?
|
|
263
|
+
*
|
|
264
|
+
* The engine says there was not by handing back a reference with an empty id.
|
|
265
|
+
* That is the cold start, and it happens on every product that has not been
|
|
266
|
+
* shipped once with the reference hook in place.
|
|
267
|
+
*
|
|
268
|
+
* @param {Verdict} verdict
|
|
269
|
+
* @returns {boolean}
|
|
270
|
+
*/
|
|
271
|
+
function nothingToCompare(verdict) {
|
|
272
|
+
return !verdict.reference || verdict.reference.id === '';
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* A build, named the way a person would name it.
|
|
277
|
+
*
|
|
278
|
+
* The same rule as `nameOf` in src/v2/run.js, written again rather than
|
|
279
|
+
* imported: this file has to keep working, and keep explaining itself, on a
|
|
280
|
+
* copy where the engine will not even load.
|
|
281
|
+
*
|
|
282
|
+
* @param {import('./types.js').BuildFingerprint} build
|
|
283
|
+
* @returns {string}
|
|
284
|
+
*/
|
|
285
|
+
function nameOfBuild(build) {
|
|
286
|
+
if (!build) return 'the build with no name';
|
|
287
|
+
if (build.version) return build.version;
|
|
288
|
+
if (build.gitSha) return build.gitSha.slice(0, 7);
|
|
289
|
+
return build.id || 'the build with no name';
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Did the person ask for the version 1 run? Only an explicit version 1 flag
|
|
294
|
+
* counts. Guessing here — "no journeys are configured, so they probably meant
|
|
295
|
+
* pictures" — is how a tool quietly does something other than what it was told.
|
|
296
|
+
*
|
|
297
|
+
* @param {import('../cli/index.js').CliContext} ctx
|
|
298
|
+
* @returns {boolean}
|
|
299
|
+
*/
|
|
300
|
+
export function wantsVersionOne(ctx) {
|
|
301
|
+
return ctx.bool('pictures') || ctx.bool('guards') || ctx.bool('watch') || ctx.bool('record');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The command line, turned into what the engine takes. Kept separate from `run`
|
|
306
|
+
* so the MCP server can build the same object from its own arguments and be
|
|
307
|
+
* certain the two front doors mean identical things.
|
|
308
|
+
*
|
|
309
|
+
* The key names are the ones the two shipped callers of the engine's front door
|
|
310
|
+
* already use — src/v2/selfcheck.js and src/v2/mcp/tools.js both pass `cwd`. A
|
|
311
|
+
* command line that sent the same value under a different name would be the one
|
|
312
|
+
* caller in three getting a silent undefined.
|
|
313
|
+
*
|
|
314
|
+
* @param {import('../cli/index.js').CliContext} ctx
|
|
315
|
+
* @returns {{cwd: string, configFile: string|undefined, against: string|undefined, paired: boolean, journeys: string|undefined, surface: string|undefined, at: string|undefined, only: string[]}}
|
|
316
|
+
*/
|
|
317
|
+
export function checkOptions(ctx) {
|
|
318
|
+
return {
|
|
319
|
+
cwd: ctx.cwd,
|
|
320
|
+
configFile: ctx.configFile,
|
|
321
|
+
against: ctx.str('against'),
|
|
322
|
+
paired: ctx.bool('paired'),
|
|
323
|
+
journeys: ctx.str('journeys'),
|
|
324
|
+
surface: ctx.str('surface'),
|
|
325
|
+
at: ctx.str('at'),
|
|
326
|
+
only: ctx.list('only'),
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* `--selfcheck`: run the corpus of deliberately broken builds and require the
|
|
332
|
+
* engine to catch every one.
|
|
333
|
+
*
|
|
334
|
+
* A tool that reports "nothing changed" looks exactly like a tool that is
|
|
335
|
+
* broken, and there is no way to tell the two apart from the outside. This is
|
|
336
|
+
* the only way to tell them apart from the inside — which is why "it could not
|
|
337
|
+
* run" is reported as a failure here and never as a quiet pass.
|
|
338
|
+
*
|
|
339
|
+
* @param {import('../cli/index.js').CliContext} ctx
|
|
340
|
+
* @param {boolean} asJson
|
|
341
|
+
* @returns {Promise<number>}
|
|
342
|
+
*/
|
|
343
|
+
async function runSelfCheck(ctx, asJson) {
|
|
344
|
+
const { selfcheck } = await import('./selfcheck.js');
|
|
345
|
+
const result = await selfcheck({ only: ctx.list('only') });
|
|
346
|
+
|
|
347
|
+
if (asJson) {
|
|
348
|
+
process.stdout.write(JSON.stringify(result) + '\n');
|
|
349
|
+
return result.passed ? EXIT.ok : EXIT.failed;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
heading('Stays Fixed — checking that it can still catch things');
|
|
353
|
+
blank();
|
|
354
|
+
|
|
355
|
+
if (!result.ran) {
|
|
356
|
+
fail(result.why ?? 'The engine could not be driven, so nothing was tested.');
|
|
357
|
+
blank();
|
|
358
|
+
fail('This is not a pass. Until it can run, a clean check means nothing.');
|
|
359
|
+
return EXIT.failed;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (result.cases.length === 0) {
|
|
363
|
+
// "Nothing matched" and "everything behaved" both leave an empty list, and
|
|
364
|
+
// filing the first one under the second is exactly the silence this whole
|
|
365
|
+
// corpus exists to make impossible.
|
|
366
|
+
fail('Nothing matched what --only asked for, so no product was tested. This is not a pass.');
|
|
367
|
+
return EXIT.failed;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
for (const one of result.cases) {
|
|
371
|
+
if (one.caught) ok(`${one.name} — ${saidOf(one)}`);
|
|
372
|
+
// A false alarm is as fatal as a miss: a tool that cries wolf gets switched
|
|
373
|
+
// off, and a tool that is switched off catches nothing. It is not a warning.
|
|
374
|
+
else fail(`${one.name} — ${saidOf(one)}`);
|
|
375
|
+
}
|
|
376
|
+
blank();
|
|
377
|
+
|
|
378
|
+
if (result.passed) {
|
|
379
|
+
ok(`All ${result.cases.length} of them behaved: every break caught, every clean pair silent.`);
|
|
380
|
+
return EXIT.ok;
|
|
381
|
+
}
|
|
382
|
+
const wrong = result.cases.filter((one) => !one.caught).length;
|
|
383
|
+
fail(`It got ${wrong} of ${result.cases.length} wrong. Until that is fixed, a clean check means nothing.`);
|
|
384
|
+
return EXIT.failed;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* One case of the corpus, in a sentence.
|
|
389
|
+
*
|
|
390
|
+
* The corpus only writes a reason when a case misbehaved, so the two good
|
|
391
|
+
* outcomes are named here — and named differently, because catching a break and
|
|
392
|
+
* staying silent on a clean pair are two separate promises.
|
|
393
|
+
*
|
|
394
|
+
* @param {import('./selfcheck.js').CaseResult} one
|
|
395
|
+
* @returns {string}
|
|
396
|
+
*/
|
|
397
|
+
function saidOf(one) {
|
|
398
|
+
if (one.why) return one.why;
|
|
399
|
+
if (one.verdict === 'caught') return 'caught, as it has to be';
|
|
400
|
+
if (one.verdict === 'quiet') return 'said nothing, which is the right answer here';
|
|
401
|
+
return one.verdict;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The engine's front door.
|
|
406
|
+
*
|
|
407
|
+
* The engine is assembled alongside this file rather than inside it, so a
|
|
408
|
+
* missing module is a real possibility while version 2 is being built and it has
|
|
409
|
+
* to read as itself. An agent told "the engine is not wired up in this copy" can
|
|
410
|
+
* act on that; an agent handed a module resolution stack trace cannot.
|
|
411
|
+
*
|
|
412
|
+
* It looks in exactly the place src/v2/mcp/tools.js looks, on purpose. If the
|
|
413
|
+
* command line and the MCP server ever found the engine in different places,
|
|
414
|
+
* they would be checking two different things and reporting it as one.
|
|
415
|
+
*
|
|
416
|
+
* @returns {Promise<(options: any) => Promise<Verdict>>}
|
|
417
|
+
*/
|
|
418
|
+
async function engineCheck() {
|
|
419
|
+
/** @type {unknown} */
|
|
420
|
+
let missing = null;
|
|
421
|
+
|
|
422
|
+
// The specifiers are built from a variable rather than written as literals.
|
|
423
|
+
// src/v2/check.js is the small piece that turns a command line into a run, and
|
|
424
|
+
// it may not be written yet; a literal would be resolved when this file is
|
|
425
|
+
// type-checked and fail there, instead of being explained in words here.
|
|
426
|
+
for (const where of ['./check.js', './run.js']) {
|
|
427
|
+
/** @type {Record<string, unknown>} */
|
|
428
|
+
let module;
|
|
429
|
+
try {
|
|
430
|
+
module = await import(where);
|
|
431
|
+
} catch (cause) {
|
|
432
|
+
const code = /** @type {{code?: string}} */ (Object(cause)).code;
|
|
433
|
+
if (code !== 'ERR_MODULE_NOT_FOUND') {
|
|
434
|
+
throw new StaysFixedError(`The difference engine could not be loaded: ${messageOf(cause)}`, { cause });
|
|
435
|
+
}
|
|
436
|
+
missing = cause;
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
const check = module.check;
|
|
440
|
+
if (typeof check === 'function') return /** @type {(options: any) => Promise<Verdict>} */ (check);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
throw new StaysFixedError('The difference engine has no front door for the command line to call.', {
|
|
444
|
+
hint:
|
|
445
|
+
'src/v2/run.js has runCheck(), but that takes a run somebody has already assembled: a store, the journeys, and something that can walk them. What is missing is the piece in between — check({cwd, configFile, against, paired, journeys, only}) returning a Verdict, exported from src/v2/check.js, which is where src/v2/mcp/tools.js and src/v2/selfcheck.js both look for it. Until it lands, `staysfixed check --pictures` and `--guards` do everything version 1 did.',
|
|
446
|
+
cause: missing ?? undefined,
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── saying what happened ────────────────────────────────────────────────────
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The verdict, in words.
|
|
454
|
+
*
|
|
455
|
+
* Everything unchanged is silent on purpose. A list of things that are fine is
|
|
456
|
+
* the exact thing this design exists to stop producing: it costs an agent its
|
|
457
|
+
* context and it teaches a person to skim.
|
|
458
|
+
*
|
|
459
|
+
* @param {Verdict} verdict
|
|
460
|
+
*/
|
|
461
|
+
export function report(verdict) {
|
|
462
|
+
if (nothingToCompare(verdict)) {
|
|
463
|
+
// One sentence, and no table. There is no build on record as working, so
|
|
464
|
+
// there are no findings, no counts and no coverage worth printing — laying
|
|
465
|
+
// out half a report around an empty middle is how a run that proved nothing
|
|
466
|
+
// gets read as a run that proved everything is fine.
|
|
467
|
+
warn(verdict.summary);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// The weakening admission goes first, and it is labelled, because this is the
|
|
472
|
+
// tool telling you it is less sure than usual. A reader who has already seen a
|
|
473
|
+
// green tick does not come back up the page for it. It is said again at the
|
|
474
|
+
// end: the engine puts it into the summary itself, so it lands either side of
|
|
475
|
+
// everything below.
|
|
476
|
+
if (verdict.modeWarning) {
|
|
477
|
+
warn('This was not a full paired run, so it is weaker than usual. Read this before anything below it:');
|
|
478
|
+
warn(verdict.modeWarning);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const sealed = verdict.findings.filter((f) => f.sealed);
|
|
482
|
+
const rest = verdict.findings.filter((f) => !f.sealed);
|
|
483
|
+
|
|
484
|
+
if (sealed.length > 0) {
|
|
485
|
+
heading('A person has to look at these');
|
|
486
|
+
blank();
|
|
487
|
+
for (const finding of sealed) printFinding(finding);
|
|
488
|
+
blank();
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (rest.length > 0) {
|
|
492
|
+
heading(sealed.length > 0 ? 'And these' : 'What changed that nobody asked for');
|
|
493
|
+
blank();
|
|
494
|
+
for (const finding of rest) printFinding(finding);
|
|
495
|
+
blank();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Paths that were steady before the change and disagree with themselves now.
|
|
499
|
+
// Nothing here has a "wrong" value, which is exactly why it needs its own
|
|
500
|
+
// section: without it a run can come back failed with no findings and no
|
|
501
|
+
// explanation of what failed.
|
|
502
|
+
const unstable = verdict.newlyUnstable ?? [];
|
|
503
|
+
if (unstable.length > 0) {
|
|
504
|
+
heading('These used to give the same answer every time, and now they do not');
|
|
505
|
+
blank();
|
|
506
|
+
for (const entry of unstable.slice(0, MOST_LINES)) {
|
|
507
|
+
say(` ${entry.path}`);
|
|
508
|
+
say(paint.grey(' two runs of this same build disagree about it, and the old build did not'));
|
|
509
|
+
}
|
|
510
|
+
if (unstable.length > MOST_LINES) say(paint.grey(` and ${unstable.length - MOST_LINES} more.`));
|
|
511
|
+
blank();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (verdict.ok) ok(`${verdict.summary}${verdict.durationMs ? ` — ${duration(verdict.durationMs)}` : ''}`);
|
|
515
|
+
else fail(verdict.summary);
|
|
516
|
+
|
|
517
|
+
sizeLine(verdict);
|
|
518
|
+
provenLine(verdict);
|
|
519
|
+
blank();
|
|
520
|
+
notCheckedBlock(verdict);
|
|
521
|
+
blank();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* @param {Finding} finding
|
|
526
|
+
*/
|
|
527
|
+
function printFinding(finding) {
|
|
528
|
+
const label = finding.sealed ? paint.red(`[${finding.class}] `) : '';
|
|
529
|
+
say(` ${label}${finding.title}`);
|
|
530
|
+
|
|
531
|
+
const example = finding.differences?.[0];
|
|
532
|
+
if (example) {
|
|
533
|
+
// "was undefined" is how a tool tells you nothing. Something that was not
|
|
534
|
+
// there before, or is not there now, has to say so in those words.
|
|
535
|
+
const { path: where, reference: was, candidate: now } = example;
|
|
536
|
+
if (example.kind === 'appeared') say(paint.grey(` ${where}: was not there before, and now it is ${show(now)}`));
|
|
537
|
+
else if (example.kind === 'vanished') say(paint.grey(` ${where}: was ${show(was)}, and now it is not there at all`));
|
|
538
|
+
else say(paint.grey(` ${where}: was ${show(was)}, now ${show(now)}`));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// The count is optional, so it is only worth a line when the engine actually
|
|
542
|
+
// filled it in and it says more than the list above already did.
|
|
543
|
+
const count = finding.count ?? finding.differences.length;
|
|
544
|
+
if (count > 1) say(paint.grey(` the same thing in ${count} places`));
|
|
545
|
+
if (finding.why) say(paint.grey(` ${finding.why}`));
|
|
546
|
+
if (finding.evidence) say(paint.grey(` evidence: ${finding.evidence}`));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* A value, short enough to read on one line.
|
|
551
|
+
* @param {unknown} value
|
|
552
|
+
* @returns {string}
|
|
553
|
+
*/
|
|
554
|
+
function show(value) {
|
|
555
|
+
const text = typeof value === 'string' ? value : JSON.stringify(value) ?? String(value);
|
|
556
|
+
return text.length > 80 ? `${text.slice(0, 77)}…` : text;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* How much was looked at. Said on a clean run as well as a dirty one: quiet that
|
|
561
|
+
* cannot be shown to be earned is indistinguishable from a broken tool.
|
|
562
|
+
*
|
|
563
|
+
* How much was SUBTRACTED is deliberately not repeated here. The engine already
|
|
564
|
+
* spells that arithmetic out inside `summary`, in its own words, and two
|
|
565
|
+
* differently-worded versions of one number on two neighbouring lines is how a
|
|
566
|
+
* reader starts wondering which of them to believe. Nor is there a line for how
|
|
567
|
+
* many suspicions the old build turned out to share: the verdict carries no such
|
|
568
|
+
* figure, and inventing one would be worse than the sentence that is already true.
|
|
569
|
+
*
|
|
570
|
+
* @param {Verdict} verdict
|
|
571
|
+
*/
|
|
572
|
+
function sizeLine(verdict) {
|
|
573
|
+
const paths = verdict.coverage?.paths ?? 0;
|
|
574
|
+
const journeys = verdict.coverage?.journeys ?? 0;
|
|
575
|
+
if (paths === 0) return;
|
|
576
|
+
say(paint.grey(` Looked at ${paths} ${paths === 1 ? 'address' : 'addresses'} across ${journeys} ${journeys === 1 ? 'journey' : 'journeys'}.`));
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* What it was compared against, and how. Two facts, and they are not the same
|
|
581
|
+
* one: the fingerprint names WHICH build, and the mode names whether that build
|
|
582
|
+
* was actually booted here or only remembered.
|
|
583
|
+
* @param {Verdict} verdict
|
|
584
|
+
*/
|
|
585
|
+
function provenLine(verdict) {
|
|
586
|
+
const how = verdict.mode === 'paired' ? 'booted and walked again on this machine' : 'the record it left the last time it ran';
|
|
587
|
+
say(paint.grey(` Compared against ${nameOfBuild(verdict.reference)} — ${how}.`));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* What it did NOT look at, under its own heading, every single time.
|
|
592
|
+
*
|
|
593
|
+
* This used to be a few grey lines at the bottom, and grey lines at the bottom are what a
|
|
594
|
+
* reader skips. The whole honesty of the tool rests on this block: a clean run on a
|
|
595
|
+
* product with three hundred doors nobody has ever opened is TRUE and it is not what it
|
|
596
|
+
* looks like. So it gets a heading, it is printed on good runs as well as bad ones, and
|
|
597
|
+
* the count of unopened doors goes first — it is the number that most often turns "it is
|
|
598
|
+
* fine" back into "it is fine as far as anybody looked".
|
|
599
|
+
*
|
|
600
|
+
* The engine says the same thing in one sentence inside `summary`, which is printed just
|
|
601
|
+
* above. That is not a duplicate: the sentence is what somebody quotes, and this is the
|
|
602
|
+
* list they act on.
|
|
603
|
+
*
|
|
604
|
+
* @param {Verdict} verdict
|
|
605
|
+
*/
|
|
606
|
+
function notCheckedBlock(verdict) {
|
|
607
|
+
const coverage = verdict.coverage;
|
|
608
|
+
if (!coverage) {
|
|
609
|
+
warn('This run did not say what it covered, so how much of your product was actually looked at is unknown. Treat the result above as unproven.');
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
heading('What this run did not check');
|
|
614
|
+
blank();
|
|
615
|
+
|
|
616
|
+
const known = coverage.doorsKnown ?? 0;
|
|
617
|
+
const unopened = Math.max(0, known - (coverage.doorsWalked ?? 0));
|
|
618
|
+
if (unopened > 0) {
|
|
619
|
+
say(
|
|
620
|
+
known === 1
|
|
621
|
+
? ' The only way into this product has never been walked through.'
|
|
622
|
+
: ` ${unopened} of the ${known} ways into this product ${unopened === 1 ? 'has' : 'have'} never been walked through.`,
|
|
623
|
+
);
|
|
624
|
+
say(
|
|
625
|
+
paint.grey(
|
|
626
|
+
` A break behind ${unopened === 1 ? 'it' : 'any of them'} is invisible to this tool. Point a journey at ${unopened === 1 ? 'it' : 'them'}, or run the project’s own test suite as journeys.`,
|
|
627
|
+
),
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Several holes can share one sentence — the coverage count's own caveats all do — and
|
|
632
|
+
// printing that sentence three times reads as three separate holes rather than as one
|
|
633
|
+
// heading with three reasons under it.
|
|
634
|
+
/** @type {Map<string, string[]>} */
|
|
635
|
+
const gaps = new Map();
|
|
636
|
+
for (const gap of coverage.gaps ?? []) {
|
|
637
|
+
if (typeof gap.doors === 'number') continue;
|
|
638
|
+
const reasons = gaps.get(gap.what) ?? [];
|
|
639
|
+
reasons.push(`${gap.why}${gap.unlockedBy ? ` ${gap.unlockedBy}` : ''}`);
|
|
640
|
+
gaps.set(gap.what, reasons);
|
|
641
|
+
}
|
|
642
|
+
let shown = 0;
|
|
643
|
+
for (const [what, reasons] of gaps) {
|
|
644
|
+
if (shown >= MOST_LINES) break;
|
|
645
|
+
shown += 1;
|
|
646
|
+
say(` ${what}`);
|
|
647
|
+
for (const reason of reasons) say(paint.grey(` ${reason}`));
|
|
648
|
+
}
|
|
649
|
+
if (gaps.size > shown) {
|
|
650
|
+
say(paint.grey(` and ${gaps.size - shown} more. All of them: staysfixed check --json`));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (unopened === 0 && gaps.size === 0) {
|
|
654
|
+
say(' Everything this run knows how to walk was walked.');
|
|
655
|
+
say(paint.grey(' That is not every possible state of your product — nothing can enumerate that. It is every way in this tool knows about.'));
|
|
656
|
+
}
|
|
657
|
+
}
|