staysfixed 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +182 -0
- package/README.md +17 -5
- package/docs/getting-started.md +10 -0
- package/docs/how-v2-works.md +5 -2
- package/package.json +2 -2
- package/src/guard/api.js +107 -3
- package/src/guard/run.js +154 -20
- package/src/report/console.js +235 -17
- package/src/report/html.js +75 -19
- package/src/types.js +5 -0
- package/src/v2/adapters/android-driver.js +62 -12
- package/src/v2/adapters/contract.js +18 -4
- package/src/v2/adapters/electron.js +96 -14
- package/src/v2/adapters/http.js +264 -23
- package/src/v2/adapters/ios-driver.js +22 -4
- package/src/v2/adapters/ios.js +5 -2
- package/src/v2/adapters/isolate.js +78 -5
- package/src/v2/adapters/process.js +350 -92
- package/src/v2/adapters/web-driver.js +23 -1
- package/src/v2/adapters/web.js +42 -3
- package/src/v2/adapters/windows.js +32 -15
- package/src/v2/check.js +526 -19
- package/src/v2/cli.js +345 -3
- package/src/v2/cluster.js +112 -4
- package/src/v2/coverage.js +293 -8
- package/src/v2/detect.js +182 -9
- package/src/v2/doctor.js +253 -30
- package/src/v2/init.js +102 -10
- package/src/v2/mcp/server.js +4 -1
- package/src/v2/mcp/tools.js +291 -24
- package/src/v2/normalise.js +11 -0
- package/src/v2/observation.js +57 -5
- package/src/v2/reference.js +133 -14
- package/src/v2/refusal.js +389 -0
- package/src/v2/remote.js +24 -3
- package/src/v2/run.js +306 -16
- package/src/v2/sealed.js +14 -2
- package/src/v2/ship.js +286 -22
- package/src/v2/store.js +101 -2
- package/src/v2/types.js +5 -0
- package/src/v2/waiver.js +9 -2
- package/src/watch/panel.js +12 -1
package/src/v2/cli.js
CHANGED
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
* sound the same whether a person or an agent is reading it.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
|
|
29
31
|
import { StaysFixedError, EXIT, messageOf } from '../core/errors.js';
|
|
30
32
|
import { say, ok, warn, fail, blank, heading, paint, duration, setLogLevel } from '../core/log.js';
|
|
31
33
|
import { openStore } from './store.js';
|
|
@@ -146,7 +148,7 @@ export const V2_COMMANDS = {
|
|
|
146
148
|
summary: 'Prove nothing that already worked has changed. This is the one you run.',
|
|
147
149
|
usage: 'staysfixed check [--against <ref>] [--paired] [--journeys <source>] [--watch] [--json]',
|
|
148
150
|
describe:
|
|
149
|
-
'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,
|
|
151
|
+
'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, have\ncommands of their own: `staysfixed intent` before you start, and `staysfixed\nwaive` afterwards. They are checked and counted — an intent has to name the\nfiles, and it has to be sealed before the run, or nothing can be waived.\n\nWith --watch it opens a window beside what is being checked and draws the run as\nit happens. Nothing this tool opens is allowed to keep taking your screen: it may\ncome up once, and from the moment you pick something else it stays behind you.\n\nThe version 1 picture check is still here: add --pictures or --guards and nothing\nabout your old command changes.',
|
|
150
152
|
options: [...V2_OPTIONS, ...WATCH_OPTIONS, ...V1_OPTIONS],
|
|
151
153
|
examples: [
|
|
152
154
|
'staysfixed check',
|
|
@@ -181,6 +183,99 @@ export const V2_COMMANDS = {
|
|
|
181
183
|
spec: { booleans: ['json', 'offline', 'fix', 'machines'] },
|
|
182
184
|
load: async () => ({ run: doctorRun }),
|
|
183
185
|
},
|
|
186
|
+
|
|
187
|
+
/*
|
|
188
|
+
* The four questions only an agent could ask.
|
|
189
|
+
*
|
|
190
|
+
* `staysfixed_coverage`, `staysfixed_explain`, `staysfixed_prove` and `staysfixed_waive`
|
|
191
|
+
* have been on the MCP server since version 2 landed, and not one of them had a command.
|
|
192
|
+
* Counted 2026-08-31: seven tools for an agent, three commands for a person. So the two
|
|
193
|
+
* readers of the same run got different answers about it, and the person got the worse
|
|
194
|
+
* one — no way to see what was NOT checked, no way to open one finding, no way to test
|
|
195
|
+
* whether their own edit caused it, and no way to record a difference as intended. Every
|
|
196
|
+
* one of those is a person being told less about their own product than the agent that
|
|
197
|
+
* changed it.
|
|
198
|
+
*
|
|
199
|
+
* None of them answers anything itself. Each calls the very tool the agent calls and
|
|
200
|
+
* prints what came back — see `askTheToolSet`.
|
|
201
|
+
*/
|
|
202
|
+
|
|
203
|
+
coverage: {
|
|
204
|
+
summary: 'What the last check did NOT look at. Read it before you call anything safe.',
|
|
205
|
+
usage: 'staysfixed coverage [--json]',
|
|
206
|
+
describe:
|
|
207
|
+
'A clean check only covers what was walked, and this is the other half of that\nsentence: the ways into your product no journey has ever opened, the surfaces this\nmachine cannot reach at all, anything refused because doing it twice would not have\nbeen reversible, and the few things this tool can never see on any machine.\n\nIt runs nothing and changes nothing — it reads the last check and this machine — so\nit answers instantly. It reports; it neither passes nor fails.\n\nAn agent asking over MCP gets this same answer, from the same code.',
|
|
208
|
+
options: [['--json', 'The whole answer as one JSON object, and no prose. For scripts and agents.']],
|
|
209
|
+
examples: ['staysfixed coverage', 'staysfixed coverage --json'],
|
|
210
|
+
spec: { booleans: ['json'] },
|
|
211
|
+
load: async () => ({ run: coverageRun }),
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
/*
|
|
215
|
+
* `intent` is the fifth, and it is here because without it the fourth cannot work.
|
|
216
|
+
*
|
|
217
|
+
* The second waiver gate refuses anything that does not fall inside an intent sealed
|
|
218
|
+
* BEFORE the run, and sealing one was an MCP tool with no command. So a `waive` command
|
|
219
|
+
* on its own would have been a control that can only ever refuse — a person types it,
|
|
220
|
+
* reads a paragraph about why the answer is no, and has no way from a terminal to make
|
|
221
|
+
* the answer yes. Proved on a real product 2026-08-31: "Refused. You did not seal an
|
|
222
|
+
* intent before this run."
|
|
223
|
+
*/
|
|
224
|
+
intent: {
|
|
225
|
+
summary: 'Seal what you meant to change, BEFORE you check.',
|
|
226
|
+
usage: 'staysfixed intent "<what you meant>" --touches <file> [--expect "<difference>"]',
|
|
227
|
+
describe:
|
|
228
|
+
'One plain sentence about what you set out to change, and the files or areas you expect\nit to affect. Seal it before you run the check.\n\nThis is what turns a later "that one was me" into something anybody can check rather\nthan a story: a difference outside what you named here cannot be waived, and an intent\nsealed after seeing what broke proves nothing, so the moment you sealed it is recorded\nagainst the state of your files at the time.\n\nIt runs nothing. Seal one, make your change, then check.',
|
|
229
|
+
options: [
|
|
230
|
+
['--touches <file>', 'A file, folder or named area you expect this to affect. Repeat it for several. At least one is required.'],
|
|
231
|
+
['--expect "<what>"', 'A difference you expect this to produce, in your own words. Optional, and it makes the check sharper.'],
|
|
232
|
+
],
|
|
233
|
+
examples: [
|
|
234
|
+
'staysfixed intent "the basket total now includes VAT" --touches src/checkout/total.js',
|
|
235
|
+
'staysfixed intent "renamed the sessions folder" --touches src/sessions --expect "every session path moves"',
|
|
236
|
+
],
|
|
237
|
+
spec: { arrays: ['touches', 'expect'] },
|
|
238
|
+
load: async () => ({ run: intentRun }),
|
|
239
|
+
},
|
|
240
|
+
|
|
241
|
+
explain: {
|
|
242
|
+
summary: 'One finding from the last check, in full.',
|
|
243
|
+
usage: 'staysfixed explain <finding> [--evidence] [--no-values] [--no-paths]',
|
|
244
|
+
describe:
|
|
245
|
+
'Every address that moved, both values in full, which class it is in, how far it sits\nfrom the code you changed, and what evidence was kept. A check reply deliberately\nleaves all of that out — it would cost more to read than it is worth on findings you\nare not going to act on — so this is where you come for the two or three you are.\n\nThe finding id is the one `staysfixed check` printed. Nothing is re-run: it reads the\nrecord of the last check, so the ids stay valid until you check again.\n\nPictures are never printed into a terminal. When one was kept, the evidence line names\nthe file and you open it yourself.',
|
|
246
|
+
options: [
|
|
247
|
+
['--evidence', 'Also print what was kept as proof — the first 40 lines of it.'],
|
|
248
|
+
['--no-values', 'Leave out the before-and-after values.'],
|
|
249
|
+
['--no-paths', 'Leave out the list of every address that moved.'],
|
|
250
|
+
],
|
|
251
|
+
examples: ['staysfixed explain f-a1b2c3', 'staysfixed explain f-a1b2c3 --evidence'],
|
|
252
|
+
spec: { booleans: ['evidence', 'values', 'paths'] },
|
|
253
|
+
load: async () => ({ run: explainRun }),
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
prove: {
|
|
257
|
+
summary: 'Test whether your own edit really caused a finding, by undoing it.',
|
|
258
|
+
usage: 'staysfixed prove <finding> --revert <file> [--revert <file>]',
|
|
259
|
+
describe:
|
|
260
|
+
'You believe your change to a particular file caused a difference. This puts that file\nback to the reference build, runs again, and says whether the difference went away.\nIf it survives, your edit did not cause it and you were about to fix the wrong thing.\n\nNothing is left reverted: the working tree is put back exactly as it was.\n\nIt answers 0 when it could test the claim and 2 when it could not. The answer itself —\ncaused it, or did not — is in the words, not the exit code, because "your edit was\ninnocent" is not a failure and must not be read as one.',
|
|
261
|
+
options: [
|
|
262
|
+
['--revert <file>', 'A file to put back to the reference for one run. Repeat it for several.'],
|
|
263
|
+
],
|
|
264
|
+
examples: ['staysfixed prove f-a1b2c3 --revert src/checkout/total.js'],
|
|
265
|
+
spec: { arrays: ['revert'] },
|
|
266
|
+
load: async () => ({ run: proveRun }),
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
waive: {
|
|
270
|
+
summary: 'Record that a difference was intended. It is not approval.',
|
|
271
|
+
usage: 'staysfixed waive <finding> --because "<why>"',
|
|
272
|
+
describe:
|
|
273
|
+
'Writes down that you meant this one. It makes nothing the new normal — only shipping\ndoes that — and four rules are enforced on it: a difference touching money, signing in,\nlosing data, a crash or a named guard can never be waived; it has to fall inside an\nintent that was sealed BEFORE the check ran; five are allowed between one ship and the\nnext; and every waiver dies the moment the reference moves.\n\nA refusal is this tool working, not this tool being difficult, and it answers 1 rather\nthan 0 so nothing downstream reads a refused waiver as a recorded one.',
|
|
274
|
+
options: [['--because "<why>"', 'Why this difference is what you meant, in one plain sentence. A person reads it later.']],
|
|
275
|
+
examples: ['staysfixed waive f-a1b2c3 --because "the basket total is meant to include VAT now"'],
|
|
276
|
+
spec: { strings: ['because'] },
|
|
277
|
+
load: async () => ({ run: waiveRun }),
|
|
278
|
+
},
|
|
184
279
|
};
|
|
185
280
|
|
|
186
281
|
/**
|
|
@@ -199,6 +294,219 @@ export async function doctorRun(ctx) {
|
|
|
199
294
|
return await v2.run(ctx);
|
|
200
295
|
}
|
|
201
296
|
|
|
297
|
+
// ── the four that only an agent used to be able to ask ──────────────────────
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The tool set carries the protocol revision the two ends of an MCP conversation agreed
|
|
301
|
+
* on. Nothing here is speaking MCP — these commands call the tool functions directly — and
|
|
302
|
+
* none of these four tools reads the field today, so it is filled in with the newest
|
|
303
|
+
* revision rather than left half-built. If one of them ever shapes its answer by protocol,
|
|
304
|
+
* this is the line that has to grow a separate answer for a terminal.
|
|
305
|
+
*/
|
|
306
|
+
const NOT_SPEAKING_MCP = '2025-06-18';
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Ask the shared tool set one question, as a person.
|
|
310
|
+
*
|
|
311
|
+
* This is deliberately a pass-through and nothing more. Two front doors that assemble the
|
|
312
|
+
* same answer twice is precisely the fault this whole tool exists to catch, so the command
|
|
313
|
+
* line does not re-derive coverage, or re-read a finding, or re-run the four waiver gates:
|
|
314
|
+
* it calls what the agent calls and prints what came back.
|
|
315
|
+
*
|
|
316
|
+
* @param {import('../cli/index.js').CliContext} ctx
|
|
317
|
+
* @param {string} tool
|
|
318
|
+
* @param {Record<string, unknown>} args
|
|
319
|
+
* @returns {Promise<{text: string, isError: boolean, structured: Record<string, unknown>|undefined}>}
|
|
320
|
+
*/
|
|
321
|
+
async function askTheToolSet(ctx, tool, args) {
|
|
322
|
+
const { callTool, findRoot } = await import('./mcp/tools.js');
|
|
323
|
+
const { rootForConfig } = await import('../core/paths.js');
|
|
324
|
+
|
|
325
|
+
// A `--config` pointing somewhere else names the project, so it decides the root — the
|
|
326
|
+
// same rule `staysfixed mcp` follows in src/cli/index.js. Dropping it here would have a
|
|
327
|
+
// person and an agent answering about two different folders.
|
|
328
|
+
const root = ctx.configFile ? rootForConfig(path.resolve(ctx.cwd, ctx.configFile)) : findRoot(ctx.cwd);
|
|
329
|
+
|
|
330
|
+
const result = await callTool(tool, args, {
|
|
331
|
+
root,
|
|
332
|
+
cwd: ctx.cwd,
|
|
333
|
+
version: ctx.version,
|
|
334
|
+
protocolVersion: NOT_SPEAKING_MCP,
|
|
335
|
+
// Who is reading. The answer is assembled once, by the same code the agent calls — that
|
|
336
|
+
// is the whole point of this function — but the sentences naming a NEXT step have to be
|
|
337
|
+
// in words this reader can use. Without this line `staysfixed intent` signed off with
|
|
338
|
+
// "Now run staysfixed_check.", `explain` offered `include: ["evidence"]`, and every
|
|
339
|
+
// intent sealed at a terminal went on the record as "an agent, over MCP". Measured
|
|
340
|
+
// 2026-08-31, the day after these five commands existed at all.
|
|
341
|
+
audience: 'person',
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const text = (result.content ?? [])
|
|
345
|
+
.filter((item) => item.type === 'text')
|
|
346
|
+
.map((item) => /** @type {{text: string}} */ (item).text)
|
|
347
|
+
.join('\n');
|
|
348
|
+
|
|
349
|
+
return { text, isError: result.isError === true, structured: result.structuredContent };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Print what came back.
|
|
354
|
+
*
|
|
355
|
+
* A refusal goes to standard error, whole. Only its first line is marked, and the rest is
|
|
356
|
+
* written plainly underneath: a waiver refusal runs to eight lines, and putting a red cross
|
|
357
|
+
* on every one of them — including its blank lines — turns the paragraph that explains what
|
|
358
|
+
* to do instead into something a person's eye slides straight off. It stays on standard
|
|
359
|
+
* error rather than becoming ordinary output because `--quiet` must never be able to
|
|
360
|
+
* swallow the sentence that says the waiver was NOT recorded.
|
|
361
|
+
*
|
|
362
|
+
* @param {{text: string, isError: boolean}} reply
|
|
363
|
+
*/
|
|
364
|
+
function sayReply(reply) {
|
|
365
|
+
blank();
|
|
366
|
+
if (reply.isError) {
|
|
367
|
+
const [first, ...rest] = reply.text.split('\n');
|
|
368
|
+
fail(first);
|
|
369
|
+
for (const line of rest) process.stderr.write(line + '\n');
|
|
370
|
+
process.stderr.write('\n');
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
for (const line of reply.text.split('\n')) say(line);
|
|
374
|
+
blank();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* `staysfixed coverage`.
|
|
379
|
+
*
|
|
380
|
+
* @param {import('../cli/index.js').CliContext} ctx
|
|
381
|
+
* @returns {Promise<number>}
|
|
382
|
+
*/
|
|
383
|
+
export async function coverageRun(ctx) {
|
|
384
|
+
const asJson = ctx.bool('json');
|
|
385
|
+
if (asJson) setLogLevel({ quiet: true });
|
|
386
|
+
|
|
387
|
+
const reply = await askTheToolSet(ctx, 'staysfixed_coverage', asJson ? { format: 'json' } : {});
|
|
388
|
+
if (reply.isError) {
|
|
389
|
+
sayReply(reply);
|
|
390
|
+
return EXIT.error;
|
|
391
|
+
}
|
|
392
|
+
if (asJson) {
|
|
393
|
+
process.stdout.write(reply.text + '\n');
|
|
394
|
+
return EXIT.ok;
|
|
395
|
+
}
|
|
396
|
+
sayReply(reply);
|
|
397
|
+
return EXIT.ok;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* `staysfixed intent "<what you meant>" --touches <file>`.
|
|
402
|
+
*
|
|
403
|
+
* @param {import('../cli/index.js').CliContext} ctx
|
|
404
|
+
* @returns {Promise<number>}
|
|
405
|
+
*/
|
|
406
|
+
export async function intentRun(ctx) {
|
|
407
|
+
const summary = ctx.args.join(' ').trim();
|
|
408
|
+
const touches = ctx.list('touches');
|
|
409
|
+
if (!summary) {
|
|
410
|
+
throw new StaysFixedError('Say what you meant to change, in one plain sentence.', {
|
|
411
|
+
hint: 'Write it as `staysfixed intent "the basket total now includes VAT" --touches src/checkout/total.js`.',
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
if (touches.length === 0) {
|
|
415
|
+
throw new StaysFixedError('Name at least one file, folder or area you expect this change to affect.', {
|
|
416
|
+
hint: 'Add `--touches <file>`, once per file. An empty list would leave you able to waive nothing at all, which is the whole point of sealing one.',
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const reply = await askTheToolSet(ctx, 'staysfixed_intent', { summary, touches, expect: ctx.list('expect') });
|
|
421
|
+
sayReply(reply);
|
|
422
|
+
return reply.isError ? EXIT.error : EXIT.ok;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* `staysfixed explain <finding>`.
|
|
427
|
+
*
|
|
428
|
+
* @param {import('../cli/index.js').CliContext} ctx
|
|
429
|
+
* @returns {Promise<number>}
|
|
430
|
+
*/
|
|
431
|
+
export async function explainRun(ctx) {
|
|
432
|
+
const finding = ctx.args[0];
|
|
433
|
+
if (!finding) {
|
|
434
|
+
throw new StaysFixedError('Say which finding to explain.', {
|
|
435
|
+
hint: 'Write it as `staysfixed explain f-a1b2c3`. The ids are the ones `staysfixed check` printed.',
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Values and paths are what an ordinary reader wants, so they are on unless switched off.
|
|
440
|
+
// Evidence is off because it can be forty lines of somebody else's log.
|
|
441
|
+
/** @type {string[]} */
|
|
442
|
+
const include = [];
|
|
443
|
+
if (ctx.flags.values !== false) include.push('values');
|
|
444
|
+
if (ctx.flags.paths !== false) include.push('paths');
|
|
445
|
+
if (ctx.bool('evidence')) include.push('evidence');
|
|
446
|
+
|
|
447
|
+
const reply = await askTheToolSet(ctx, 'staysfixed_explain', { finding, include });
|
|
448
|
+
sayReply(reply);
|
|
449
|
+
return reply.isError ? EXIT.error : EXIT.ok;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* `staysfixed prove <finding> --revert <file>`.
|
|
454
|
+
*
|
|
455
|
+
* The exit code says whether the claim could be TESTED, never which way it came out. "Your
|
|
456
|
+
* edit did not cause this" is one of the two right answers and it is the more useful one —
|
|
457
|
+
* exiting non-zero on it would train somebody to stop reading the sentence underneath.
|
|
458
|
+
*
|
|
459
|
+
* @param {import('../cli/index.js').CliContext} ctx
|
|
460
|
+
* @returns {Promise<number>}
|
|
461
|
+
*/
|
|
462
|
+
export async function proveRun(ctx) {
|
|
463
|
+
const finding = ctx.args[0];
|
|
464
|
+
const revert = ctx.list('revert');
|
|
465
|
+
if (!finding) {
|
|
466
|
+
throw new StaysFixedError('Say which finding you are trying to explain.', {
|
|
467
|
+
hint: 'Write it as `staysfixed prove f-a1b2c3 --revert src/checkout/total.js`.',
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
if (revert.length === 0) {
|
|
471
|
+
throw new StaysFixedError('Name what to put back to the reference for one run.', {
|
|
472
|
+
hint: 'Add `--revert <file>`, once per file. Without one there is no claim to test.',
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const reply = await askTheToolSet(ctx, 'staysfixed_prove', { finding, revert });
|
|
477
|
+
sayReply(reply);
|
|
478
|
+
return reply.isError ? EXIT.error : EXIT.ok;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* `staysfixed waive <finding> --because "<why>"`.
|
|
483
|
+
*
|
|
484
|
+
* Anything other than a recorded waiver answers non-zero. A refusal, a bad id and a project
|
|
485
|
+
* that has never been checked all mean the same thing to whatever runs next: it was NOT
|
|
486
|
+
* recorded. Exiting 0 on any of them is the false all-clear this tool exists to prevent.
|
|
487
|
+
*
|
|
488
|
+
* @param {import('../cli/index.js').CliContext} ctx
|
|
489
|
+
* @returns {Promise<number>}
|
|
490
|
+
*/
|
|
491
|
+
export async function waiveRun(ctx) {
|
|
492
|
+
const finding = ctx.args[0];
|
|
493
|
+
const because = ctx.str('because');
|
|
494
|
+
if (!finding) {
|
|
495
|
+
throw new StaysFixedError('Say which finding you are recording as intended.', {
|
|
496
|
+
hint: 'Write it as `staysfixed waive f-a1b2c3 --because "..."`.',
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
if (!because) {
|
|
500
|
+
throw new StaysFixedError('Say why this difference is what you meant, in one plain sentence.', {
|
|
501
|
+
hint: 'Add `--because "the basket total is meant to include VAT now"`. A waiver with no reason is worth nothing to whoever reads it in six months.',
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const reply = await askTheToolSet(ctx, 'staysfixed_waive', { finding, because });
|
|
506
|
+
sayReply(reply);
|
|
507
|
+
return reply.isError ? EXIT.failed : EXIT.ok;
|
|
508
|
+
}
|
|
509
|
+
|
|
202
510
|
/**
|
|
203
511
|
* `staysfixed check`.
|
|
204
512
|
*
|
|
@@ -234,8 +542,25 @@ export async function run(ctx) {
|
|
|
234
542
|
if (ctx.bool('selfcheck')) return await runSelfCheck(ctx, asJson);
|
|
235
543
|
|
|
236
544
|
const check = await engineCheck();
|
|
545
|
+
|
|
546
|
+
// A person watching a run has to be told what it is waiting for.
|
|
547
|
+
//
|
|
548
|
+
// The run says things while it works — "the site came up at http://[::1]:51492", "the old
|
|
549
|
+
// build did not shut down cleanly", "this run could not be saved" — and every one of those
|
|
550
|
+
// reached the live panel and nothing else. At a terminal the whole of a ninety-second wait
|
|
551
|
+
// was a blank screen, which is indistinguishable from the tool being broken, and that is
|
|
552
|
+
// the state somebody kills the run in. Switched off for --json, where one stray sentence on
|
|
553
|
+
// standard output is a reply that will not parse.
|
|
554
|
+
const { makeCheckEvents } = await import('./run.js');
|
|
555
|
+
const events = makeCheckEvents();
|
|
556
|
+
if (!asJson) {
|
|
557
|
+
events.on((event) => {
|
|
558
|
+
if (event.type === 'note' && event.message) say(paint.dim(String(event.message)));
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
237
562
|
/** @type {Verdict} */
|
|
238
|
-
const verdict = await check(checkOptions(ctx));
|
|
563
|
+
const verdict = await check({ ...checkOptions(ctx), events });
|
|
239
564
|
|
|
240
565
|
// Write down what this check concluded, before printing anything.
|
|
241
566
|
//
|
|
@@ -590,6 +915,18 @@ export function report(verdict) {
|
|
|
590
915
|
blank();
|
|
591
916
|
}
|
|
592
917
|
|
|
918
|
+
// What to do with the names in square brackets.
|
|
919
|
+
//
|
|
920
|
+
// The agent's reply has always ended with the calls that open one of these up. The
|
|
921
|
+
// person's ended with the list and nothing else, so the ids were decoration: three
|
|
922
|
+
// commands exist that take one and nothing told anybody they were there.
|
|
923
|
+
if (verdict.findings.length > 0) {
|
|
924
|
+
const one = verdict.findings[0].id;
|
|
925
|
+
say(paint.grey(` Open one of these up: ${paint.cyan(`staysfixed explain ${one}`)}`));
|
|
926
|
+
say(paint.grey(` Test whether your own edit caused it: ${paint.cyan(`staysfixed prove ${one} --revert <file>`)}`));
|
|
927
|
+
blank();
|
|
928
|
+
}
|
|
929
|
+
|
|
593
930
|
// Paths that were steady before the change and disagree with themselves now.
|
|
594
931
|
// Nothing here has a "wrong" value, which is exactly why it needs its own
|
|
595
932
|
// section: without it a run can come back failed with no findings and no
|
|
@@ -621,7 +958,12 @@ export function report(verdict) {
|
|
|
621
958
|
*/
|
|
622
959
|
function printFinding(finding) {
|
|
623
960
|
const label = finding.sealed ? paint.red(`[${finding.class}] `) : '';
|
|
624
|
-
|
|
961
|
+
// The id, first, exactly as the agent's reply gives it. `explain`, `prove` and `waive`
|
|
962
|
+
// all take one and there was nowhere on the command line to read one from: the ids were
|
|
963
|
+
// written into the record and into every MCP reply, and a person running the same check
|
|
964
|
+
// never saw them at all.
|
|
965
|
+
const name = finding.id ? paint.grey(`[${finding.id}] `) : '';
|
|
966
|
+
say(` ${name}${label}${finding.title}`);
|
|
625
967
|
|
|
626
968
|
const example = finding.differences?.[0];
|
|
627
969
|
if (example) {
|
package/src/v2/cluster.js
CHANGED
|
@@ -372,6 +372,47 @@ export function describe(d, count, rename, identical = true) {
|
|
|
372
372
|
|
|
373
373
|
switch (d.kind) {
|
|
374
374
|
case 'changed': {
|
|
375
|
+
// A RENAME INSIDE ONE VALUE, before anything else is tried. `findRenames` above only
|
|
376
|
+
// ever sees a rename that arrived as two addresses — one vanished, one appeared — and
|
|
377
|
+
// a JSON field is not addressed that way: the whole reply is one observation at one
|
|
378
|
+
// address, so renaming a field is a single `changed` difference and every line below
|
|
379
|
+
// this one describes it as something other than a rename.
|
|
380
|
+
//
|
|
381
|
+
// Measured 2026-08-30 on a six-field JSON reply with `name` renamed to `fullName`. The
|
|
382
|
+
// summary of a set of details prints its first four field names, both sides came out in
|
|
383
|
+
// the same words, and `whatMoved` then walked the union of the keys in alphabetical
|
|
384
|
+
// order and stopped at the first one that differed — `fullName`, the half that ARRIVED.
|
|
385
|
+
// The sentence was: `"GET /api/user / shape" now has "fullName" reading "string" where
|
|
386
|
+
// it read nothing.` The word `name` was nowhere in it, on the one channel whose whole
|
|
387
|
+
// promise is that "a renamed or dropped field shows up on its own". Every caller still
|
|
388
|
+
// reading `user.name` was broken and the report named only the field to start using.
|
|
389
|
+
const swap = renamedField(d.reference, d.candidate);
|
|
390
|
+
if (swap) {
|
|
391
|
+
return `${where}, "${name}" no longer has "${swap.from}" — the same value is under "${swap.to}" now, so anything still reading "${swap.from}" gets nothing.${spread}`;
|
|
392
|
+
}
|
|
393
|
+
const moved = whatMoved(d.reference, d.candidate);
|
|
394
|
+
// A FIELD THAT IS NO LONGER THERE, OR ONE THAT HAS TURNED UP, IS NAMED — and it is named
|
|
395
|
+
// ahead of the summary below, not only when the summary happens to fail. A set of
|
|
396
|
+
// details is summarised by its field COUNT and its first four names, so a reply that
|
|
397
|
+
// lost `name` from six fields read "is now a set of details (5 fields: "address",
|
|
398
|
+
// "avatar", "created", "email", and more) where it was a set of details (6 fields:
|
|
399
|
+
// "address", "avatar", "created", "email", and more)". Both halves true, both halves
|
|
400
|
+
// ending in "and more", and the one word that moved hidden inside the "and more" on
|
|
401
|
+
// each side. A dropped field is the loudest thing this tool finds and a reader could not
|
|
402
|
+
// see which one it was. Measured 2026-08-30 alongside the rename above.
|
|
403
|
+
//
|
|
404
|
+
// "now has X reading nothing" was the old wording for a field that had gone, and it is
|
|
405
|
+
// not true either: the value does not HAVE that field any more.
|
|
406
|
+
if (moved?.side === 'gone') {
|
|
407
|
+
return moved.what === ''
|
|
408
|
+
? `${where}, "${name}" is no longer there at all. It read ${moved.was}.${spread}`
|
|
409
|
+
: `${where}, "${name}" has lost "${moved.what}", which read ${moved.was}. Anything still reading it gets nothing.${spread}`;
|
|
410
|
+
}
|
|
411
|
+
if (moved?.side === 'arrived') {
|
|
412
|
+
return moved.what === ''
|
|
413
|
+
? `${where}, "${name}" reads ${moved.now} now, and read nothing before.${spread}`
|
|
414
|
+
: `${where}, "${name}" has gained "${moved.what}", reading ${moved.now}. Nothing was there before.${spread}`;
|
|
415
|
+
}
|
|
375
416
|
const now = describeValue(d.candidate);
|
|
376
417
|
const was = describeValue(d.reference);
|
|
377
418
|
if (now !== was) return `${where}, "${name}" is now ${now} where it was ${was}.${spread}`;
|
|
@@ -382,7 +423,6 @@ export function describe(d, count, rename, identical = true) {
|
|
|
382
423
|
// it was a set of details (one field: line)" — twice the same words, on the tool's own
|
|
383
424
|
// flagship example, in the paragraph a person reads rather than an agent. So the
|
|
384
425
|
// summary is put down and the thing that actually moved is named instead.
|
|
385
|
-
const moved = whatMoved(d.reference, d.candidate);
|
|
386
426
|
if (!moved) return `${where}, "${name}" changed, and both versions of it read the same at this length.${spread}`;
|
|
387
427
|
return moved.what === ''
|
|
388
428
|
? `${where}, "${name}" now reads ${moved.now} where it read ${moved.was}.${spread}`
|
|
@@ -501,15 +541,32 @@ export function describeValue(value) {
|
|
|
501
541
|
* @param {ObservedValue|undefined} reference
|
|
502
542
|
* @param {ObservedValue|undefined} candidate
|
|
503
543
|
* @param {string[]} [trail]
|
|
504
|
-
* @returns {{what: string, was: string, now: string}|null}
|
|
544
|
+
* @returns {{what: string, was: string, now: string, side?: 'gone'|'arrived'}|null}
|
|
545
|
+
* `side` says the field is on one side only: `gone` means it was there and is not, which is
|
|
546
|
+
* the half a caller breaks on, and `arrived` means the opposite. Absent means both sides
|
|
547
|
+
* have it and only the value moved.
|
|
505
548
|
*/
|
|
506
549
|
function whatMoved(reference, candidate, trail = []) {
|
|
507
550
|
if (isSetOfDetails(reference) && isSetOfDetails(candidate)) {
|
|
508
|
-
|
|
551
|
+
// THE FIELDS THAT WENT AWAY ARE WALKED FIRST, and until 2026-08-30 the union of both
|
|
552
|
+
// sides' keys was walked in plain alphabetical order. That order decides which single
|
|
553
|
+
// field the sentence ends up naming, and it was deciding it by spelling: rename `name` to
|
|
554
|
+
// `fullName` and `fullName` sorts first, so the report named the field that had just been
|
|
555
|
+
// invented and never the one that had gone. A field that arrived breaks nobody. A field
|
|
556
|
+
// that went away breaks every caller that reads it, so it is the one the sentence owes
|
|
557
|
+
// the reader when only one of them can be named.
|
|
558
|
+
const before = Object.keys(reference).sort();
|
|
559
|
+
const after = Object.keys(candidate).sort();
|
|
560
|
+
const gone = before.filter((k) => !(k in /** @type {object} */ (candidate)));
|
|
561
|
+
const rest = [...before.filter((k) => k in /** @type {object} */ (candidate)), ...after.filter((k) => !(k in /** @type {object} */ (reference)))];
|
|
562
|
+
for (const key of [...gone, ...rest]) {
|
|
509
563
|
const a = /** @type {Record<string, any>} */ (reference)[key];
|
|
510
564
|
const b = /** @type {Record<string, any>} */ (candidate)[key];
|
|
511
565
|
if (sameValue(a, b)) continue;
|
|
512
|
-
|
|
566
|
+
const side = !(key in /** @type {object} */ (candidate)) ? 'gone' : !(key in /** @type {object} */ (reference)) ? 'arrived' : undefined;
|
|
567
|
+
const deeper = whatMoved(a, b, [...trail, key]);
|
|
568
|
+
if (!deeper) return null;
|
|
569
|
+
return side ? { ...deeper, side } : deeper;
|
|
513
570
|
}
|
|
514
571
|
return null;
|
|
515
572
|
}
|
|
@@ -533,6 +590,57 @@ function whatMoved(reference, candidate, trail = []) {
|
|
|
533
590
|
return was === now ? null : { what, was, now };
|
|
534
591
|
}
|
|
535
592
|
|
|
593
|
+
/**
|
|
594
|
+
* One field swapped for another INSIDE a single value.
|
|
595
|
+
*
|
|
596
|
+
* `findRenames` above spots a rename that arrived as two ADDRESSES, one vanished and one
|
|
597
|
+
* appeared. That is how a screen control or a source door is addressed, and it is not how a
|
|
598
|
+
* JSON reply is: the whole body, and separately the whole shape of it, is one observation at
|
|
599
|
+
* one address, so renaming a field there produces exactly one `changed` difference and the
|
|
600
|
+
* pairing above never gets a chance to look at it. This is the same test, applied to the two
|
|
601
|
+
* values rather than to two addresses: in the same place, one name went away, one arrived,
|
|
602
|
+
* and they hold the same value.
|
|
603
|
+
*
|
|
604
|
+
* The conditions are the strict ones on purpose, for the same reason they are strict up
|
|
605
|
+
* there. Exactly one of each — two of each is a rewrite, and picking the pairs would be
|
|
606
|
+
* fiction. The values must match, or these are two unrelated edits that happened to land in
|
|
607
|
+
* the same object. And everything the two sides still share has to be untouched, because the
|
|
608
|
+
* caller prints ONE sentence: if something else moved as well, saying only "renamed" would be
|
|
609
|
+
* a true sentence that leaves out the rest, and the field-by-field wording below is the
|
|
610
|
+
* honest answer instead.
|
|
611
|
+
*
|
|
612
|
+
* @param {ObservedValue|undefined} reference
|
|
613
|
+
* @param {ObservedValue|undefined} candidate
|
|
614
|
+
* @param {string[]} [trail] How far in we are, for a rename nested inside the reply.
|
|
615
|
+
* @returns {{from: string, to: string}|null}
|
|
616
|
+
*/
|
|
617
|
+
function renamedField(reference, candidate, trail = []) {
|
|
618
|
+
if (!isSetOfDetails(reference) || !isSetOfDetails(candidate)) return null;
|
|
619
|
+
const was = /** @type {Record<string, ObservedValue>} */ (reference);
|
|
620
|
+
const is = /** @type {Record<string, ObservedValue>} */ (candidate);
|
|
621
|
+
const before = Object.keys(was);
|
|
622
|
+
const after = Object.keys(is);
|
|
623
|
+
const gone = before.filter((k) => !(k in is));
|
|
624
|
+
const came = after.filter((k) => !(k in was));
|
|
625
|
+
|
|
626
|
+
if (gone.length === 1 && came.length === 1) {
|
|
627
|
+
if (!sameValue(was[gone[0]], is[came[0]])) return null;
|
|
628
|
+
// Everything they still share has to be the same, or the rename is not the whole story.
|
|
629
|
+
for (const key of before) {
|
|
630
|
+
if (key === gone[0]) continue;
|
|
631
|
+
if (!sameValue(was[key], is[key])) return null;
|
|
632
|
+
}
|
|
633
|
+
return { from: [...trail, gone[0]].join(' / '), to: [...trail, came[0]].join(' / ') };
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Same names at this level, so the rename — if there is one — is further in. Only one
|
|
637
|
+
// field may have moved, for the same reason as above: two moved fields is not one rename.
|
|
638
|
+
if (gone.length > 0 || came.length > 0) return null;
|
|
639
|
+
const moved = before.filter((k) => !sameValue(was[k], is[k]));
|
|
640
|
+
if (moved.length !== 1) return null;
|
|
641
|
+
return renamedField(was[moved[0]], is[moved[0]], [...trail, moved[0]]);
|
|
642
|
+
}
|
|
643
|
+
|
|
536
644
|
/**
|
|
537
645
|
* A window round the first character two pieces of text stop agreeing at, with enough either
|
|
538
646
|
* side to recognise the place.
|