staysfixed 0.10.0 → 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/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, 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\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.',
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
- say(` ${label}${finding.title}`);
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
- for (const key of [...new Set([...Object.keys(reference), ...Object.keys(candidate)])].sort()) {
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
- return whatMoved(a, b, [...trail, key]);
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.