staysfixed 0.6.2 → 0.7.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 +187 -0
- package/README.md +104 -43
- package/docs/design-v2.md +275 -0
- package/docs/getting-started.md +295 -0
- package/docs/guards.md +226 -0
- package/docs/how-it-stays-stable.md +315 -0
- package/docs/how-v2-works.md +403 -0
- package/docs/mcp.md +286 -0
- package/docs/running-it-in-ci.md +306 -0
- package/docs/watching.md +190 -0
- package/package.json +3 -3
- package/src/cli/index.js +34 -7
- package/src/core/config.js +12 -2
- package/src/v2/adapters/isolate.js +89 -0
- package/src/v2/cause.js +151 -12
- package/src/v2/check.js +345 -3
- package/src/v2/cli.js +71 -12
- package/src/v2/cluster.js +20 -3
- package/src/v2/coverage.js +40 -5
- package/src/v2/detect.js +1413 -20
- package/src/v2/doctor.js +191 -35
- package/src/v2/init.js +470 -57
- package/src/v2/mcp/tools.js +124 -11
- package/src/v2/normalise.js +54 -7
- package/src/v2/observation.js +56 -10
- package/src/v2/rank.js +212 -43
- package/src/v2/run.js +216 -31
- package/src/v2/selfcheck.js +312 -7
- package/src/v2/store.js +269 -45
- package/src/v2/watch/index.js +96 -17
- package/src/v2/watch/window.js +138 -20
package/src/v2/mcp/tools.js
CHANGED
|
@@ -292,18 +292,50 @@ function storeFor(ctx) {
|
|
|
292
292
|
// The list
|
|
293
293
|
// ---------------------------------------------------------------------------
|
|
294
294
|
|
|
295
|
+
/**
|
|
296
|
+
* What each tool DOES to the machine, in the four flags the protocol defines for it.
|
|
297
|
+
*
|
|
298
|
+
* These are not decoration. A client uses them to decide what it may run without stopping
|
|
299
|
+
* to ask a person, and an agent uses them to tell a question apart from an action. Getting
|
|
300
|
+
* them wrong in either direction is a real cost: mark a tool read-only when it is not and
|
|
301
|
+
* something runs unasked; mark a harmless question as an action and every session opens
|
|
302
|
+
* with a prompt nobody needed.
|
|
303
|
+
*
|
|
304
|
+
* They are set honestly here rather than optimistically. `staysfixed_check` reads nothing
|
|
305
|
+
* but it OPENS YOUR PRODUCT — twice — so it is not read-only, and two runs of it can
|
|
306
|
+
* legitimately answer differently, so it is not idempotent either. `staysfixed_prove` puts
|
|
307
|
+
* files back to the reference for one run and restores them afterwards; nothing survives
|
|
308
|
+
* it, and an agent should still know it touches the working tree.
|
|
309
|
+
*
|
|
310
|
+
* `openWorldHint` is false on every one of them, and that is the whole design in one flag:
|
|
311
|
+
* nothing here reaches a server, an account or the internet. There is nowhere to sign up.
|
|
312
|
+
*
|
|
313
|
+
* @param {{title: string, readOnly?: boolean, destructive?: boolean, idempotent?: boolean}} a
|
|
314
|
+
* @returns {Record<string, unknown>}
|
|
315
|
+
*/
|
|
316
|
+
function behaves({ title, readOnly = false, destructive = false, idempotent = false }) {
|
|
317
|
+
return { title, readOnlyHint: readOnly, destructiveHint: destructive, idempotentHint: idempotent, openWorldHint: false };
|
|
318
|
+
}
|
|
319
|
+
|
|
295
320
|
/**
|
|
296
321
|
* The `tools/list` payload. Static, and it never touches disk: an agent listing
|
|
297
322
|
* tools in a project that is not set up must still see
|
|
298
323
|
* `staysfixed_capabilities`, which is the tool that explains why nothing else
|
|
299
324
|
* will work yet.
|
|
300
325
|
*
|
|
301
|
-
*
|
|
326
|
+
* Every entry carries three things an agent reads before it calls anything: a short
|
|
327
|
+
* `title` a person would recognise in a permission prompt, a description long enough to
|
|
328
|
+
* say WHEN to call it and not merely what it is, and `annotations` saying what it does to
|
|
329
|
+
* the machine. See `behaves`.
|
|
330
|
+
*
|
|
331
|
+
* @returns {{name: string, title: string, description: string, inputSchema: Record<string, any>, annotations: Record<string, unknown>}[]}
|
|
302
332
|
*/
|
|
303
333
|
export function toolDefinitions() {
|
|
304
334
|
return [
|
|
305
335
|
{
|
|
306
336
|
name: 'staysfixed_capabilities',
|
|
337
|
+
title: 'What can be checked here',
|
|
338
|
+
annotations: behaves({ title: 'What can be checked here', readOnly: true, idempotent: true }),
|
|
307
339
|
description:
|
|
308
340
|
'CALL THIS FIRST, once per session. What Stays Fixed can check on this machine right now, what it cannot and why, what is missing that would unlock more, which other machines it can already reach, and the exact shape of every reply you will get back. It runs nothing and changes nothing. After this call you should not need to read any documentation about this tool.',
|
|
309
341
|
inputSchema: {
|
|
@@ -319,6 +351,8 @@ export function toolDefinitions() {
|
|
|
319
351
|
},
|
|
320
352
|
{
|
|
321
353
|
name: 'staysfixed_intent',
|
|
354
|
+
title: 'Seal what you meant to change',
|
|
355
|
+
annotations: behaves({ title: 'Seal what you meant to change' }),
|
|
322
356
|
description:
|
|
323
357
|
'Seal what you MEANT to change, BEFORE you run a check. One plain sentence, the files or areas you expect to affect, and the differences you expect to see. This is what makes a later "that one was me" claim checkable instead of a story: you cannot waive a difference outside what you sealed, and you cannot seal an intent after seeing what broke. Call it once per change, right before or right after you edit.',
|
|
324
358
|
inputSchema: {
|
|
@@ -338,6 +372,8 @@ export function toolDefinitions() {
|
|
|
338
372
|
},
|
|
339
373
|
{
|
|
340
374
|
name: 'staysfixed_check',
|
|
375
|
+
title: 'Check what changed',
|
|
376
|
+
annotations: behaves({ title: 'Check what changed' }),
|
|
341
377
|
description:
|
|
342
378
|
'Run it. Puts the build you just changed through the same steps as the build that was last shipped, twice, and reports only the differences that are left after the product\'s own wobble is subtracted. Covers what the screen says a control does, what calls go out, what files are written, what errors appear, what the program prints, and what the code exposes. You get back ONLY what you did not account for, ranked with the differences furthest from your edit at the top, because those are side effects. Everything unchanged is silent. Seal an intent first.',
|
|
343
379
|
inputSchema: {
|
|
@@ -349,7 +385,11 @@ export function toolDefinitions() {
|
|
|
349
385
|
type: 'boolean',
|
|
350
386
|
description: 'Boot the old build live and walk it from the start instead of trusting the stored record. Slower and much stronger. Use it before a release, and on the first run of a product with no stored record.',
|
|
351
387
|
},
|
|
352
|
-
journeys: {
|
|
388
|
+
journeys: {
|
|
389
|
+
type: 'string',
|
|
390
|
+
description:
|
|
391
|
+
"Where the steps come from. 'code' is the default and needs nothing: each adapter reads your source and offers what it finds - routes, commands, screens, message channels. The other value is a path to a journeys file naming steps by hand. 'suite' (harvest your own test suite) and 'recorded' (replay a recorded session) are written and not yet wired into a run: ask for either and it says so rather than checking something else.",
|
|
392
|
+
},
|
|
353
393
|
surface: {
|
|
354
394
|
type: 'string',
|
|
355
395
|
enum: ['auto', 'cli', 'library', 'server', 'web', 'electron', 'android', 'ios'],
|
|
@@ -370,6 +410,8 @@ export function toolDefinitions() {
|
|
|
370
410
|
},
|
|
371
411
|
{
|
|
372
412
|
name: 'staysfixed_explain',
|
|
413
|
+
title: 'One finding in full',
|
|
414
|
+
annotations: behaves({ title: 'One finding in full', readOnly: true, idempotent: true }),
|
|
373
415
|
description:
|
|
374
416
|
'One finding, in depth: every address that moved, both values in full, what class it is in, how far it sits from your edit, and the evidence. This is where the heavy material lives - it is never pushed into a check reply, so ask for it on the two or three findings you actually intend to act on.',
|
|
375
417
|
inputSchema: {
|
|
@@ -388,6 +430,8 @@ export function toolDefinitions() {
|
|
|
388
430
|
},
|
|
389
431
|
{
|
|
390
432
|
name: 'staysfixed_prove',
|
|
433
|
+
title: 'Prove what caused it',
|
|
434
|
+
annotations: behaves({ title: 'Prove what caused it' }),
|
|
391
435
|
description:
|
|
392
436
|
'Test a causal claim by undoing a change and running again. You believe your edit to a particular file caused a finding: this puts that file back to the reference, re-runs, and tells you whether the difference went away. If it survives the revert, your edit did not cause it and you were about to fix the wrong thing. Nothing is left reverted.',
|
|
393
437
|
inputSchema: {
|
|
@@ -402,6 +446,8 @@ export function toolDefinitions() {
|
|
|
402
446
|
},
|
|
403
447
|
{
|
|
404
448
|
name: 'staysfixed_waive',
|
|
449
|
+
title: 'Record a difference as intended',
|
|
450
|
+
annotations: behaves({ title: 'Record a difference as intended' }),
|
|
405
451
|
description:
|
|
406
452
|
'Record that a difference was intended. This is NOT approval and it makes nothing the new normal - only shipping does that. Four rules are enforced and cannot be argued with: differences touching money, signing in, losing data, a crash, or a named guard can never be waived; the difference has to fall inside what you sealed with staysfixed_intent before the run; five between one ship and the next; and every waiver dies the moment the reference moves. If a waiver is refused, that is the answer - fix the code instead.',
|
|
407
453
|
inputSchema: {
|
|
@@ -416,6 +462,8 @@ export function toolDefinitions() {
|
|
|
416
462
|
},
|
|
417
463
|
{
|
|
418
464
|
name: 'staysfixed_coverage',
|
|
465
|
+
title: 'What was not checked',
|
|
466
|
+
annotations: behaves({ title: 'What was not checked', readOnly: true, idempotent: true }),
|
|
419
467
|
description:
|
|
420
468
|
'What was NOT checked. The ways in that no journey has ever opened, the surfaces this machine cannot reach at all, anything refused because doing it twice would not have been reversible, and the things this tool can never see on any machine. Read it before you tell anyone a change is safe: a clean check only covers what was walked, and this is the list of what was not.',
|
|
421
469
|
inputSchema: {
|
|
@@ -708,6 +756,21 @@ async function toolCheck(ctx, input) {
|
|
|
708
756
|
const limit = positive(input.limit) ?? DEFAULT_LIMIT;
|
|
709
757
|
const offset = positive(input.offset) ?? 0;
|
|
710
758
|
|
|
759
|
+
// A value the engine does not understand must be refused BY NAME. `suite` and
|
|
760
|
+
// `recorded` are real ideas with real code behind them in src/v2/journeys/, and
|
|
761
|
+
// nothing on the check path calls that code yet - so passing either one down reaches
|
|
762
|
+
// the engine as the name of a file, and comes back as "there is no journeys file at
|
|
763
|
+
// .../suite". That error sends an agent looking for a file it never asked for. The
|
|
764
|
+
// day the harvest is wired, this refusal is what has to be deleted.
|
|
765
|
+
const wantedJourneys = text(input.journeys);
|
|
766
|
+
if (wantedJourneys === 'suite' || wantedJourneys === 'recorded') {
|
|
767
|
+
return problem(
|
|
768
|
+
wantedJourneys === 'suite'
|
|
769
|
+
? 'Harvesting your own test suite as journeys is written and not wired into a run yet, so nothing was checked. Leave journeys out to use the steps each adapter reads from your source, or pass the path to a journeys file. Saying this rather than quietly checking something else is deliberate: a clean result about the wrong steps is worse than no result.'
|
|
770
|
+
: 'Replaying a recorded session is written and not wired into a run yet, so nothing was checked. Leave journeys out to use the steps each adapter reads from your source, or pass the path to a journeys file.'
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
|
|
711
774
|
const surface = text(input.surface);
|
|
712
775
|
const at = text(input.at);
|
|
713
776
|
const aimed = (surface !== null && surface !== 'auto') || at !== null;
|
|
@@ -725,7 +788,7 @@ async function toolCheck(ctx, input) {
|
|
|
725
788
|
configFile: undefined,
|
|
726
789
|
against: text(input.against) ?? undefined,
|
|
727
790
|
paired: input.paired === true,
|
|
728
|
-
journeys:
|
|
791
|
+
journeys: wantedJourneys ?? undefined,
|
|
729
792
|
only: stringList(input.only) ?? [],
|
|
730
793
|
surface: surface && surface !== 'auto' ? surface : undefined,
|
|
731
794
|
at: at ?? undefined,
|
|
@@ -893,7 +956,12 @@ function renderCheck({ result, unaccounted, page, offset, limit, waived, expired
|
|
|
893
956
|
// same otherwise, and one of those is a broken tool reporting success.
|
|
894
957
|
/** @type {string[]} */
|
|
895
958
|
const arithmetic = [];
|
|
896
|
-
|
|
959
|
+
// "journeys", never "ways in". A DOOR is a way in — a route, a command, an exported
|
|
960
|
+
// name — and the coverage sentence directly below this one counts doors. Calling both
|
|
961
|
+
// of them "ways in" put "2 ways in were walked" one line above "2 of the 2 ways into
|
|
962
|
+
// this product have never been walked through", which is a flat contradiction on screen
|
|
963
|
+
// even though both numbers are right. Two different things need two different words.
|
|
964
|
+
if (result?.coverage) arithmetic.push(`${result.coverage.journeys} ${result.coverage.journeys === 1 ? 'journey was' : 'journeys were'} walked`);
|
|
897
965
|
if (typeof result?.differencesNoise === 'number' && result.differencesNoise > 0) arithmetic.push(`${result.differencesNoise} differences subtracted as this product's own wobble`);
|
|
898
966
|
if (waived) arithmetic.push(`${waived} already recorded as intended and not shown again`);
|
|
899
967
|
if (arithmetic.length) out.push(arithmetic.join(', ') + '.');
|
|
@@ -910,7 +978,12 @@ function renderCheck({ result, unaccounted, page, offset, limit, waived, expired
|
|
|
910
978
|
'Compared against the STORED RECORD, not against the old build booted live. That is genuinely weaker: it lets back in every difference that comes from the machine and the day rather than from your change. Pass paired: true for the strong comparison.'
|
|
911
979
|
);
|
|
912
980
|
}
|
|
913
|
-
|
|
981
|
+
// The engine's summary ends with the same "not everything was checked" sentence that is
|
|
982
|
+
// already printed under the headline, because both come from the one place that is
|
|
983
|
+
// allowed to write it. Said twice in one reply it reads as a stutter, and an agent
|
|
984
|
+
// paying by the token pays for it twice, so the second copy is taken out here rather
|
|
985
|
+
// than by weakening either of the two rules that put it there.
|
|
986
|
+
if (result?.summary) out.push(withoutRepeat(result.summary, notChecked));
|
|
914
987
|
|
|
915
988
|
if (newlyUnstable.length) {
|
|
916
989
|
out.push('');
|
|
@@ -1022,6 +1095,24 @@ function coverageSentence(engine, result) {
|
|
|
1022
1095
|
return `NOT EVERYTHING WAS CHECKED: ${unopened} ways into this product have never been walked through, and ${gaps} other things were not looked at. A clean result only covers what was walked — staysfixed_coverage has the list.`;
|
|
1023
1096
|
}
|
|
1024
1097
|
|
|
1098
|
+
/**
|
|
1099
|
+
* One sentence, said once.
|
|
1100
|
+
*
|
|
1101
|
+
* Returns `text` with `sentence` removed if it is in there, tidied so the seam does not
|
|
1102
|
+
* show. Both strings come from the engine, so this never edits meaning - it only stops the
|
|
1103
|
+
* same words arriving twice in one reply.
|
|
1104
|
+
*
|
|
1105
|
+
* @param {string} text
|
|
1106
|
+
* @param {string} sentence
|
|
1107
|
+
* @returns {string}
|
|
1108
|
+
*/
|
|
1109
|
+
function withoutRepeat(text, sentence) {
|
|
1110
|
+
const trimmed = sentence.trim();
|
|
1111
|
+
if (trimmed === '' || !text.includes(trimmed)) return text;
|
|
1112
|
+
const left = text.replace(trimmed, '').replace(/[ \t]{2,}/g, ' ').trim();
|
|
1113
|
+
return left === '' ? text : left;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1025
1116
|
/**
|
|
1026
1117
|
* Did the run go where it was aimed?
|
|
1027
1118
|
*
|
|
@@ -1118,8 +1209,11 @@ async function toolExplain(ctx, input) {
|
|
|
1118
1209
|
|
|
1119
1210
|
/** @type {string[]} */
|
|
1120
1211
|
const out = [];
|
|
1212
|
+
// `classify` returns a whole verdict, not a class name, and interpolating the object
|
|
1213
|
+
// printed "(SEALED: [object Object])" on the one reply an agent reads when it is trying
|
|
1214
|
+
// to understand a difference it is not allowed to waive.
|
|
1121
1215
|
const sealed = classify(f);
|
|
1122
|
-
out.push(f.title + (sealed ? ` (SEALED: ${sealed} - not yours to waive)` : ''));
|
|
1216
|
+
out.push(f.title + (sealed ? ` (SEALED: ${sealed.says} - not yours to waive)` : ''));
|
|
1123
1217
|
if (typeof f.distance === 'number') {
|
|
1124
1218
|
out.push(f.distance === 0 ? 'This sits inside the code you changed.' : `This sits ${f.distance} away from the code you changed, which is why it is ranked where it is. The further away, the more it looks like a side effect.`);
|
|
1125
1219
|
}
|
|
@@ -1132,7 +1226,11 @@ async function toolExplain(ctx, input) {
|
|
|
1132
1226
|
if (paths.length > 60) out.push(` and ${paths.length - 60} more.`);
|
|
1133
1227
|
}
|
|
1134
1228
|
|
|
1135
|
-
|
|
1229
|
+
// The engine's own deep answer lists every address with both values in full. When it is
|
|
1230
|
+
// there, printing a one-address sample above it is the same text twice in one reply -
|
|
1231
|
+
// and an agent pays for both copies.
|
|
1232
|
+
const engineShowsValues = typeof deep?.text === 'string' && f.sample != null && deep.text.includes(stringy(f.sample.candidate).trim());
|
|
1233
|
+
if (include.includes('values') && !engineShowsValues) {
|
|
1136
1234
|
out.push('');
|
|
1137
1235
|
if (f.sample) {
|
|
1138
1236
|
out.push(`BEFORE - ${f.sample.path}`);
|
|
@@ -1360,7 +1458,13 @@ async function toolCoverage(ctx, input) {
|
|
|
1360
1458
|
// machine could run. A Mac with Xcode on it can run an iPhone app; that says nothing
|
|
1361
1459
|
// about whether there is an adapter here that knows how to open one.
|
|
1362
1460
|
cannotBeDriven: (caps?.drivers ?? []).filter((/** @type {any} */ d) => !d.present).map((/** @type {any} */ d) => ({ surface: d.surface, why: d.why })),
|
|
1363
|
-
|
|
1461
|
+
// The whole gap, not just its headline. Several of the caveats the engine raises
|
|
1462
|
+
// share one headline — "This coverage count is less exact than it looks" — and
|
|
1463
|
+
// differ entirely in `why`, so a list of headlines is the same sentence three times
|
|
1464
|
+
// and none of the three reasons. The reason IS the content.
|
|
1465
|
+
unopened: (coverage?.gaps ?? [])
|
|
1466
|
+
.filter((/** @type {{doors?: number}} */ g) => typeof g.doors !== 'number')
|
|
1467
|
+
.map((/** @type {{what: string, why?: string, unlockedBy?: string}} */ g) => ({ what: g.what, why: g.why ?? null, unlockedBy: g.unlockedBy ?? null })),
|
|
1364
1468
|
surfacesOutOfReach: unreachable.map((/** @type {any} */ s) => ({ name: s.name, why: s.summary, needs: s.needs })),
|
|
1365
1469
|
surfacesPartial: partial.map((/** @type {any} */ s) => ({ name: s.name, why: s.summary })),
|
|
1366
1470
|
neverVisible: caps?.limits ?? null,
|
|
@@ -1388,10 +1492,11 @@ async function toolCoverage(ctx, input) {
|
|
|
1388
1492
|
// not look at it. Printing the sentence keeps the reason attached to the hole. The
|
|
1389
1493
|
// doors gap is left out here because it is already counted, in its own words, just
|
|
1390
1494
|
// above — and a number a reader can catch out twice is a number they stop believing.
|
|
1495
|
+
/** @type {{what: string, why: string, unlockedBy: string}[]} */
|
|
1391
1496
|
const unopened = (coverage.gaps ?? [])
|
|
1392
1497
|
.filter((/** @type {{doors?: number}} */ g) => typeof g.doors !== 'number')
|
|
1393
|
-
.map((/** @type {{what: string}} */ g) => g.what);
|
|
1394
|
-
out.push(`The last run walked ${coverage.journeys} ${coverage.journeys === 1 ? '
|
|
1498
|
+
.map((/** @type {{what: string, why?: string, unlockedBy?: string}} */ g) => ({ what: String(g.what), why: String(g.why ?? ''), unlockedBy: String(g.unlockedBy ?? '') }));
|
|
1499
|
+
out.push(`The last run walked ${coverage.journeys} ${coverage.journeys === 1 ? 'journey' : 'journeys'}. A journey is one route through the product; a door is one way into it, and they are counted separately below.`);
|
|
1395
1500
|
const doorsKnown = coverage.doorsKnown ?? 0;
|
|
1396
1501
|
const never = Math.max(0, doorsKnown - (coverage.doorsWalked ?? 0));
|
|
1397
1502
|
if (never > 0) {
|
|
@@ -1405,7 +1510,15 @@ async function toolCoverage(ctx, input) {
|
|
|
1405
1510
|
out.push('It opened every way in that it knows about. That is not the same as every possible state - nothing can enumerate that - but there is no known door it has never been through.');
|
|
1406
1511
|
} else if (unopened.length > 0) {
|
|
1407
1512
|
out.push(`${unopened.length} other ${unopened.length === 1 ? 'thing was' : 'things were'} not looked at, so nothing in any check says anything about ${unopened.length === 1 ? 'it' : 'them'}:`);
|
|
1408
|
-
for (const
|
|
1513
|
+
for (const gap of unopened.slice(0, 30)) {
|
|
1514
|
+
out.push(`- ${trim(gap.what, 160)}`);
|
|
1515
|
+
// The reason on its own line and never dropped. Three gaps here can carry the
|
|
1516
|
+
// same headline and three different reasons, and printing only the headline
|
|
1517
|
+
// turned that into the same sentence three times over - which reads as a bug in
|
|
1518
|
+
// the tool rather than as three separate holes in the coverage.
|
|
1519
|
+
if (gap.why) out.push(` ${trim(gap.why, 400)}`);
|
|
1520
|
+
if (gap.unlockedBy && !/^Read the caveat/i.test(gap.unlockedBy)) out.push(` What would close it: ${trim(gap.unlockedBy, 300)}`);
|
|
1521
|
+
}
|
|
1409
1522
|
if (unopened.length > 30) out.push(`- and ${unopened.length - 30} more.`);
|
|
1410
1523
|
}
|
|
1411
1524
|
}
|
package/src/v2/normalise.js
CHANGED
|
@@ -34,6 +34,13 @@ import { canonicalJson, matchPath } from './observation.js';
|
|
|
34
34
|
/** Where in a value we are, when a rule needs to say. */
|
|
35
35
|
const ROOT = '$';
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* How deep into a value the tidying goes. It matches the depth an observation is allowed to
|
|
39
|
+
* be in the first place (MAX_VALUE_DEPTH in observation.js), so on anything this tool made
|
|
40
|
+
* itself the limit is never reached; it only bites on a value read back off the disk.
|
|
41
|
+
*/
|
|
42
|
+
const MAX_WALK_DEPTH = 64;
|
|
43
|
+
|
|
37
44
|
/** Sentinel for a value a `drop` rule removed. Never appears in a result. */
|
|
38
45
|
const DROPPED = Symbol('dropped');
|
|
39
46
|
|
|
@@ -632,7 +639,24 @@ export function normaliseCapture(capture, rules) {
|
|
|
632
639
|
* @returns {ObservedValue|typeof DROPPED}
|
|
633
640
|
*/
|
|
634
641
|
function walk(node, at, rules, record, depth) {
|
|
635
|
-
if (depth >
|
|
642
|
+
if (depth > MAX_WALK_DEPTH) {
|
|
643
|
+
// Everything from here down goes into the comparison exactly as it arrived: no clock is
|
|
644
|
+
// rubbed out, no id is flattened. That is churn arriving as findings rather than a real
|
|
645
|
+
// finding disappearing, so it is the safe direction — but it is still a piece of the
|
|
646
|
+
// value nothing looked at, and a receipt is the only reason anybody would ever know.
|
|
647
|
+
if (record) {
|
|
648
|
+
record.push({
|
|
649
|
+
ruleId: 'depth.limit',
|
|
650
|
+
what: `Part of this value nests deeper than ${MAX_WALK_DEPTH} levels.`,
|
|
651
|
+
why: 'Tidying stops there, so nothing below it was rewritten before comparing.',
|
|
652
|
+
wouldHide: 'Nothing. It hides the opposite: timestamps and ids down there are compared as they are, so they will report as differences on every run.',
|
|
653
|
+
at,
|
|
654
|
+
before: '(not rewritten)',
|
|
655
|
+
after: '(not rewritten)',
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
return node;
|
|
659
|
+
}
|
|
636
660
|
|
|
637
661
|
for (const rule of rules) {
|
|
638
662
|
if (rule.kind === 'drop' && ruleAppliesAt(rule, at)) {
|
|
@@ -670,17 +694,40 @@ function walk(node, at, rules, record, depth) {
|
|
|
670
694
|
return items;
|
|
671
695
|
}
|
|
672
696
|
|
|
673
|
-
|
|
674
|
-
|
|
697
|
+
// A Map, not an object, and this is not a style choice. `newKey in out` asks the whole
|
|
698
|
+
// prototype chain, so on a plain `{}` the answer for `toString`, `constructor`, `valueOf`
|
|
699
|
+
// and `hasOwnProperty` is always yes — and every one of those entries was silently thrown
|
|
700
|
+
// away, on BOTH sides of the comparison, so a change inside one of them could never be
|
|
701
|
+
// seen. Worse, `out['__proto__'] = value` sets the prototype instead of storing anything,
|
|
702
|
+
// which loses the entry and quietly rewires the object. A Map has neither problem.
|
|
703
|
+
/** @type {Map<string, ObservedValue>} */
|
|
704
|
+
const kept = new Map();
|
|
675
705
|
for (const [key, child] of Object.entries(node)) {
|
|
676
706
|
const newKey = rewriteKey(key, at, rules, record);
|
|
677
707
|
const value = walk(/** @type {ObservedValue} */ (child), `${at}.${key}`, rules, record, depth + 1);
|
|
678
708
|
if (value === DROPPED) continue;
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
709
|
+
if (!kept.has(newKey)) {
|
|
710
|
+
kept.set(newKey, value);
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
// Two keys tidied down to one name. The first wins, and the second is GONE — never
|
|
714
|
+
// compared with anything, at an address that no longer exists. This is the loss `keys`
|
|
715
|
+
// is off by default because of, and when a project switches it on anyway the loss is at
|
|
716
|
+
// least written down instead of happening in silence.
|
|
717
|
+
const first = kept.get(newKey);
|
|
718
|
+
if (record && canonicalJson(/** @type {ObservedValue} */ (first)) !== canonicalJson(value)) {
|
|
719
|
+
record.push({
|
|
720
|
+
ruleId: 'keys.collision',
|
|
721
|
+
what: `Two entries here ended up with the same name, "${newKey}".`,
|
|
722
|
+
why: 'A rule that rewrites keys turned two different names into one, and only the first entry survives.',
|
|
723
|
+
wouldHide: 'The second entry entirely. Nothing compares it against anything, so a change inside it cannot be seen. Sort the collection and observe its members by position instead.',
|
|
724
|
+
at: `${at}.${key} (key)`,
|
|
725
|
+
before: clip(canonicalJson(value)),
|
|
726
|
+
after: '<lost to a name it now shares>',
|
|
727
|
+
});
|
|
728
|
+
}
|
|
682
729
|
}
|
|
683
|
-
return
|
|
730
|
+
return Object.fromEntries(kept);
|
|
684
731
|
}
|
|
685
732
|
|
|
686
733
|
/**
|
package/src/v2/observation.js
CHANGED
|
@@ -329,30 +329,65 @@ function isPlainObject(v) {
|
|
|
329
329
|
* @returns {string}
|
|
330
330
|
*/
|
|
331
331
|
export function canonicalJson(value) {
|
|
332
|
-
return
|
|
332
|
+
return canonicalForm(value).json;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The canonical string, and whether anything had to be given up to produce it.
|
|
337
|
+
*
|
|
338
|
+
* The flag is the whole point. Below `MAX_VALUE_DEPTH` the canonical form stands in for the
|
|
339
|
+
* value exactly; past it, the deep part is replaced by a marker, and TWO DIFFERENT deep
|
|
340
|
+
* values collapse to the same string. Comparing those two strings and calling them equal is
|
|
341
|
+
* the tool saying "these are the same" about a pair it never finished reading, which is the
|
|
342
|
+
* one sentence it may never say. So the flag travels out and `sameValue` refuses.
|
|
343
|
+
*
|
|
344
|
+
* @param {ObservedValue} value
|
|
345
|
+
* @returns {{json: string, tooDeep: boolean}}
|
|
346
|
+
*/
|
|
347
|
+
function canonicalForm(value) {
|
|
348
|
+
const how = { tooDeep: false };
|
|
349
|
+
return { json: JSON.stringify(canonicalise(value, 0, how)) ?? 'null', tooDeep: how.tooDeep };
|
|
333
350
|
}
|
|
334
351
|
|
|
335
352
|
/**
|
|
336
353
|
* @param {ObservedValue} value
|
|
337
354
|
* @param {number} depth
|
|
355
|
+
* @param {{tooDeep: boolean}} how
|
|
338
356
|
* @returns {unknown}
|
|
339
357
|
*/
|
|
340
|
-
function canonicalise(value, depth) {
|
|
341
|
-
if (depth > MAX_VALUE_DEPTH)
|
|
358
|
+
function canonicalise(value, depth, how) {
|
|
359
|
+
if (depth > MAX_VALUE_DEPTH) {
|
|
360
|
+
how.tooDeep = true;
|
|
361
|
+
return '<too deep>';
|
|
362
|
+
}
|
|
342
363
|
if (typeof value === 'number' && !Number.isFinite(value)) return `<number:${String(value)}>`;
|
|
343
|
-
if (Array.isArray(value)) return value.map((v) => canonicalise(v, depth + 1));
|
|
364
|
+
if (Array.isArray(value)) return value.map((v) => canonicalise(v, depth + 1, how));
|
|
344
365
|
if (isPlainObject(value)) {
|
|
345
|
-
|
|
346
|
-
|
|
366
|
+
// Built through a Map and `Object.fromEntries`, because plain assignment cannot hold a
|
|
367
|
+
// key called `__proto__`: `out['__proto__'] = x` rewires the object instead of storing
|
|
368
|
+
// anything, so every object with that key canonicalised to `{}` and any two of them
|
|
369
|
+
// compared EQUAL however different their contents. A JSON body with a `__proto__` field
|
|
370
|
+
// in it is not exotic; it is what a product prints when it dumps data it was handed.
|
|
371
|
+
/** @type {Map<string, unknown>} */
|
|
372
|
+
const out = new Map();
|
|
347
373
|
for (const key of Object.keys(value).sort()) {
|
|
348
|
-
out
|
|
374
|
+
out.set(key, canonicalise(/** @type {ObservedValue} */ (value[key]), depth + 1, how));
|
|
349
375
|
}
|
|
350
|
-
return out;
|
|
376
|
+
return Object.fromEntries(out);
|
|
351
377
|
}
|
|
352
378
|
return value;
|
|
353
379
|
}
|
|
354
380
|
|
|
355
381
|
/**
|
|
382
|
+
* Are these two values the same value?
|
|
383
|
+
*
|
|
384
|
+
* "I could not finish reading either of them" is not the same answer as "yes", and until
|
|
385
|
+
* 2026-08-30 it came back as one: anything nesting deeper than the tool reads was flattened
|
|
386
|
+
* to the text `<too deep>`, so two completely different deep values compared EQUAL and every
|
|
387
|
+
* difference inside them vanished without a word. `makeObservation` refuses to make one that
|
|
388
|
+
* deep, so this only ever happens to a value read back off the disk — which is precisely the
|
|
389
|
+
* value nobody watched being made.
|
|
390
|
+
*
|
|
356
391
|
* @param {ObservedValue|undefined} a
|
|
357
392
|
* @param {ObservedValue|undefined} b
|
|
358
393
|
* @returns {boolean}
|
|
@@ -360,7 +395,12 @@ function canonicalise(value, depth) {
|
|
|
360
395
|
export function sameValue(a, b) {
|
|
361
396
|
if (a === undefined || b === undefined) return a === b;
|
|
362
397
|
if (a === b) return true;
|
|
363
|
-
|
|
398
|
+
const x = canonicalForm(a);
|
|
399
|
+
const y = canonicalForm(b);
|
|
400
|
+
// Different is the safe answer and the honest one: it makes the pair a finding somebody
|
|
401
|
+
// reads rather than a silence nobody can see.
|
|
402
|
+
if (x.tooDeep || y.tooDeep) return false;
|
|
403
|
+
return x.json === y.json;
|
|
364
404
|
}
|
|
365
405
|
|
|
366
406
|
/**
|
|
@@ -823,7 +863,13 @@ export function wobbleStorm(wobble) {
|
|
|
823
863
|
const looked = unstable + wobble.steady;
|
|
824
864
|
const vanished = wobble.entries.filter((e) => e.kind === 'vanished').length;
|
|
825
865
|
const share = looked === 0 ? 0 : unstable / looked;
|
|
826
|
-
|
|
866
|
+
// Nothing held still at all. This is the one case the share and the floor between them let
|
|
867
|
+
// through: a journey with eight addresses where all eight wobble is 100% of the comparison
|
|
868
|
+
// thrown away, and the floor exists to stop three-out-of-four being called a storm, not to
|
|
869
|
+
// excuse a comparison that ended up empty. Zero steady addresses is not a tuned number; it
|
|
870
|
+
// is the arithmetic saying there was nothing left to compare.
|
|
871
|
+
const nothingHeldStill = wobble.measured && wobble.steady === 0 && unstable > 0;
|
|
872
|
+
if (!wobble.measured || (!nothingHeldStill && (looked < STORM_FLOOR || share <= STORM_SHARE))) {
|
|
827
873
|
return { stormy: false, share, looked, vanished, why: '' };
|
|
828
874
|
}
|
|
829
875
|
const percent = Math.round(share * 100);
|