staysfixed 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +342 -0
  2. package/README.md +191 -55
  3. package/docs/design-v2.md +24 -4
  4. package/docs/getting-started.md +18 -5
  5. package/docs/guards.md +2 -2
  6. package/docs/how-v2-works.md +12 -11
  7. package/docs/mcp.md +17 -8
  8. package/docs/settings.md +549 -0
  9. package/docs/watching.md +10 -4
  10. package/examples/staysfixed.config.electron.js +17 -6
  11. package/examples/staysfixed.config.web.js +22 -5
  12. package/package.json +2 -1
  13. package/src/cli/index.js +55 -46
  14. package/src/cli/watch-flags.js +54 -0
  15. package/src/core/config.js +23 -3
  16. package/src/guard/run.js +49 -1
  17. package/src/report/console.js +15 -2
  18. package/src/v2/adapters/android-driver.js +6 -1
  19. package/src/v2/adapters/android.js +97 -2
  20. package/src/v2/adapters/contract.js +42 -5
  21. package/src/v2/adapters/electron.js +72 -6
  22. package/src/v2/adapters/http.js +11 -2
  23. package/src/v2/adapters/ios-driver.js +64 -14
  24. package/src/v2/adapters/ios.js +247 -25
  25. package/src/v2/adapters/process.js +728 -66
  26. package/src/v2/adapters/python.js +495 -0
  27. package/src/v2/adapters/source.js +373 -18
  28. package/src/v2/adapters/web-driver.js +94 -24
  29. package/src/v2/adapters/web.js +142 -9
  30. package/src/v2/adapters/windows.js +18 -1
  31. package/src/v2/browsers.js +9 -1
  32. package/src/v2/cause.js +61 -17
  33. package/src/v2/check.js +530 -66
  34. package/src/v2/ci.js +130 -35
  35. package/src/v2/cli.js +42 -24
  36. package/src/v2/cluster.js +164 -13
  37. package/src/v2/coverage.js +43 -176
  38. package/src/v2/detect.js +308 -60
  39. package/src/v2/doctor.js +285 -45
  40. package/src/v2/init.js +162 -61
  41. package/src/v2/intent.js +9 -23
  42. package/src/v2/journeys/from-suite.js +336 -30
  43. package/src/v2/journeys/index.js +99 -6
  44. package/src/v2/mcp/tools.js +10 -11
  45. package/src/v2/normalise.js +169 -23
  46. package/src/v2/observation.js +19 -33
  47. package/src/v2/rank.js +216 -23
  48. package/src/v2/reference.js +40 -10
  49. package/src/v2/remote.js +113 -18
  50. package/src/v2/run.js +103 -14
  51. package/src/v2/sealed.js +0 -20
  52. package/src/v2/selfcheck.js +190 -13
  53. package/src/v2/ship.js +29 -5
  54. package/src/v2/store.js +67 -1
  55. package/src/v2/types.js +12 -2
  56. package/src/v2/waiver.js +64 -54
  57. package/src/v2/watch/events.js +60 -215
  58. package/src/v2/watch/focus.js +14 -4
  59. package/src/v2/watch/panel.js +167 -17
@@ -264,9 +264,22 @@ export const OPTIONAL_RULES = [
264
264
  ];
265
265
 
266
266
  /**
267
- * A version stamp for the shipped set. Bump it when the defaults change, so a stored capture
268
- * normalised under the old rules is not silently compared against one normalised under the new
269
- * ones see `rulesFingerprint`, which is the mechanism that actually catches it.
267
+ * A version stamp for the shipped set, and a thing to be careful with.
268
+ *
269
+ * It is the prefix on every fingerprint, so bumping it does not mark one rule as changed — it
270
+ * marks EVERY stored capture in the world as normalised under different rules, all at once, and
271
+ * every project's next check reports "compared across a change to the normalisation rules" until
272
+ * somebody ships again. That is a large, silent cost for a number nobody thinks twice about.
273
+ *
274
+ * So bump it only when a change alters what a comparison SHOWS. There is already a way to add a
275
+ * rule without paying it, and it should be the first thing tried: ship the new rule `off: true`.
276
+ * The fingerprint hashes `activeRules` only, so a rule nobody has switched on does not move it,
277
+ * the rule is documented and copyable from the day it ships, and each project turns it on when
278
+ * it wants it — which is one project's stored record going stale rather than everyone's.
279
+ *
280
+ * A genuinely new `kind` is the case that cannot dodge it: an old copy of the tool meets a rule
281
+ * it does not know how to apply, and pretending the two are comparable would be worse than the
282
+ * churn. Bump it there, and say so in the changelog.
270
283
  */
271
284
  export const RULES_VERSION = 1;
272
285
 
@@ -325,10 +338,17 @@ export function assertRules(rules) {
325
338
  * Merge a project's rules over the shipped ones.
326
339
  *
327
340
  * Same id wins, so a project turns a default off by writing `{id, off: true}` and narrows one
328
- * by writing `{id, paths: [...]}` — no need to restate a rule to change one field of it.
341
+ * by writing `{id, paths: [...]}` — no need to restate a rule to change one field of it. The
342
+ * type says so too now; it used to promise whole rules only, which made the one shape the
343
+ * paragraph above recommends a type error.
344
+ *
345
+ * A part-rule whose id matches nothing is REFUSED rather than inserted. Inserted, it became a
346
+ * rule with no `kind`, which every branch of the walker skips — so a project that misspelled
347
+ * `clock.iso` got exactly the silence of a rule that was working perfectly, on the one file
348
+ * whose whole job is to be auditable.
329
349
  *
330
350
  * @param {NormaliseRule[]} base
331
- * @param {NormaliseRule[]} extra
351
+ * @param {(NormaliseRule | (Partial<NormaliseRule> & {id: string}))[]} extra
332
352
  * @returns {NormaliseRule[]}
333
353
  */
334
354
  export function mergeRules(base, extra) {
@@ -337,7 +357,14 @@ export function mergeRules(base, extra) {
337
357
  for (const rule of base) byId.set(rule.id, rule);
338
358
  for (const rule of extra) {
339
359
  const existing = byId.get(rule.id);
340
- byId.set(rule.id, existing ? { ...existing, ...rule } : rule);
360
+ if (!existing && ruleProblem(rule)) {
361
+ throw new StaysFixedError(`There is no normalisation rule called "${rule.id}" to change.`, {
362
+ hint: 'Check the spelling against the rule list, or write the whole rule out if you meant to add a new one.',
363
+ });
364
+ }
365
+ // Cast, because the refusal above is what proves it: an entry with no rule to merge
366
+ // into has already been through ruleProblem and come back clean, so it is a whole rule.
367
+ byId.set(rule.id, existing ? { ...existing, ...rule } : /** @type {NormaliseRule} */ (rule));
341
368
  }
342
369
  return [...byId.values()];
343
370
  }
@@ -352,34 +379,145 @@ export function activeRules(rules) {
352
379
  }
353
380
 
354
381
  /**
355
- * A short fingerprint of a rule set.
382
+ * A short fingerprint of what a rule set DOES.
356
383
  *
357
384
  * Stored on every capture. Comparing a capture normalised under one set of rules against one
358
- * normalised under another is meaningless — the differences you see are the rules changing,
359
- * not the product — and this is what lets the run notice and say so.
385
+ * normalised under another is meaningless — the differences you see are the rules changing, not
386
+ * the product — and this is what lets the run notice and say so.
387
+ *
388
+ * Two things are deliberately NOT in it, and both were bugs before they were decisions.
389
+ *
390
+ * WHERE A RULE APPLIES IS NOT WHAT IT DOES. `paths`, `channels` and `at` are scope. A rule that
391
+ * learns one more address goes on tidying everything it already tidied in exactly the same way,
392
+ * so a stored comparison is not suspect and saying it is spends the reader's attention on
393
+ * nothing. Scope is stamped separately by `rulesScope` and reported in its own quieter words,
394
+ * because a rule reaching a NEW address genuinely can start hiding something there and the
395
+ * honest thing is to name the address rather than to shrug or to shout.
396
+ *
397
+ * A FACT ABOUT THIS MACHINE IS NOT A RULE. `machineRules` builds patterns out of where the
398
+ * project is checked out, where home is, and what temp folder this machine handed out — and one
399
+ * of those is a fresh mkdtemp on every single run. Hashing those patterns made this fingerprint
400
+ * change on every run of the same binary, so the "the rules changed" caveat fired forever, on
401
+ * runs where nothing had changed at all, and never healed. It also emptied the caveat of
402
+ * meaning, which is worse: a warning that is always on is a warning nobody reads on the day it
403
+ * is true. Those rules go in by `id` and `kind` alone. The test is not "is this rule identical"
404
+ * but "would a change to it change what a comparison shows", and two machines both rewriting
405
+ * their own checkout to `<project>` produce the same normalised value.
360
406
  *
361
407
  * @param {NormaliseRule[]} rules
362
408
  * @returns {string}
363
409
  */
364
410
  export function rulesFingerprint(rules) {
365
411
  const active = activeRules(rules)
366
- .map((r) => ({
367
- id: r.id,
368
- kind: r.kind,
369
- pattern: r.pattern ?? '',
370
- flags: r.flags ?? '',
371
- with: r.with ?? '',
372
- digits: r.digits ?? 0,
373
- keys: r.keys ?? false,
374
- numbers: r.numbers ?? false,
375
- paths: [...(r.paths ?? [])].sort(),
376
- channels: [...(r.channels ?? [])].sort(),
377
- at: [...(r.at ?? [])].sort(),
378
- }))
412
+ .map((r) =>
413
+ r.machine
414
+ ? { id: r.id, kind: r.kind, machine: true }
415
+ : {
416
+ id: r.id,
417
+ kind: r.kind,
418
+ pattern: r.pattern ?? '',
419
+ flags: r.flags ?? '',
420
+ with: r.with ?? '',
421
+ digits: r.digits ?? 0,
422
+ keys: r.keys ?? false,
423
+ numbers: r.numbers ?? false,
424
+ })
379
425
  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
380
426
  return `v${RULES_VERSION}-${sha256(JSON.stringify(active)).slice(0, 12)}`;
381
427
  }
382
428
 
429
+ /**
430
+ * Where each switched-on rule applies, as data rather than as a hash.
431
+ *
432
+ * Kept readable on purpose. The fingerprint can be a hash because the only question ever asked
433
+ * of it is "same or not"; scope has to answer "what is covered now that was not covered before",
434
+ * and no hash can. It is small — a handful of globs on a handful of rules — and it is what turns
435
+ * "the rules changed" into "this rule now also covers screen.checkout.total".
436
+ *
437
+ * @param {NormaliseRule[]} rules
438
+ * @returns {Record<string, string[]>} Rule id to its globs, sorted. Rules that apply everywhere
439
+ * are left out: an unscoped rule has nothing to say here.
440
+ */
441
+ export function rulesScope(rules) {
442
+ /** @type {Record<string, string[]>} */
443
+ const scope = {};
444
+ for (const rule of activeRules(rules)) {
445
+ const where = [
446
+ ...(rule.paths ?? []),
447
+ ...(rule.channels ?? []).map((c) => `channel:${c}`),
448
+ ...(rule.at ?? []).map((a) => `inside:${a}`),
449
+ ].sort();
450
+ if (where.length > 0) scope[rule.id] = where;
451
+ }
452
+ return scope;
453
+ }
454
+
455
+ /**
456
+ * What actually changed between the rules a stored capture was tidied by and today's.
457
+ *
458
+ * This exists because the caveat it feeds used to print two hashes. An agent reading
459
+ * "v1-fcf4b8000217 versus v1-29141a9ec069" can do nothing with either of them — it cannot tell
460
+ * whether to distrust the comparison, re-run it, or ignore the line, so it ignores the line.
461
+ *
462
+ * The three answers are different sizes of news and are said as such:
463
+ * behaviour changed A rule now rewrites something differently. The comparison IS suspect.
464
+ * scope changed A rule reaches somewhere it did not. Only that address is suspect, and
465
+ * it is named, because "somewhere" is not something anybody can act on.
466
+ * scope unknown The stored capture predates the scope stamp. Say that, rather than
467
+ * diffing against an empty object and reporting every glob as new.
468
+ *
469
+ * @param {{fingerprint?: string, scope?: Record<string, string[]>}} before From the stored capture.
470
+ * @param {{fingerprint?: string, scope?: Record<string, string[]>}} after From this run.
471
+ * @returns {{same: boolean, behaviourChanged: boolean, scopeChanged: boolean, addressesNewlyCovered: string[], say: string}}
472
+ */
473
+ export function describeRuleChange(before, after) {
474
+ const none = { same: true, behaviourChanged: false, scopeChanged: false, addressesNewlyCovered: [], say: '' };
475
+ if (!before.fingerprint || !after.fingerprint) return none;
476
+
477
+ const behaviourChanged = before.fingerprint !== after.fingerprint;
478
+ const knowScope = before.scope !== undefined && after.scope !== undefined;
479
+
480
+ /** @type {string[]} */
481
+ const newlyCovered = [];
482
+ if (knowScope) {
483
+ const was = /** @type {Record<string, string[]>} */ (before.scope);
484
+ const now = /** @type {Record<string, string[]>} */ (after.scope);
485
+ for (const [id, globs] of Object.entries(now)) {
486
+ const had = new Set(was[id] ?? []);
487
+ for (const glob of globs) if (!had.has(glob)) newlyCovered.push(`${id} now also covers ${glob}`);
488
+ }
489
+ // A rule that STOPPED covering an address can only show differences that were being hidden,
490
+ // which is the safe direction and not worth a line of anybody's attention.
491
+ }
492
+ const scopeChanged = newlyCovered.length > 0;
493
+
494
+ if (!behaviourChanged && !scopeChanged) {
495
+ return { ...none, same: !behaviourChanged && !scopeChanged };
496
+ }
497
+
498
+ /** @type {string[]} */
499
+ const parts = [];
500
+ if (behaviourChanged) {
501
+ parts.push(
502
+ 'A normalisation rule rewrites something differently now than when the old build was recorded, '
503
+ + 'so some of what you see below may be the rules changing rather than the product, and a rule '
504
+ + 'that grew stricter since could be covering something up.',
505
+ );
506
+ }
507
+ if (scopeChanged) {
508
+ const named = newlyCovered.slice(0, 4).join('; ');
509
+ const more = newlyCovered.length > 4 ? `, and ${newlyCovered.length - 4} more` : '';
510
+ parts.push(
511
+ `A rule reaches somewhere it did not reach before: ${named}${more}. Everything else compares `
512
+ + 'normally; only what those cover could be quieter than it should be.',
513
+ );
514
+ } else if (behaviourChanged && !knowScope) {
515
+ parts.push('The old record predates the scope stamp, so which addresses each rule covered then cannot be compared.');
516
+ }
517
+
518
+ return { same: false, behaviourChanged, scopeChanged, addressesNewlyCovered: newlyCovered, say: parts.join(' ') };
519
+ }
520
+
383
521
  /**
384
522
  * The rule set in plain English, for `doctor` and for any agent asking the tool what it does.
385
523
  * Says both what is on and what is deliberately off, because the second list is the more
@@ -427,6 +565,7 @@ export function machineRules(where) {
427
565
  wouldHide: 'Nothing about the product. It only ever replaces a prefix that is a fact about the machine.',
428
566
  pattern: literal(where.root),
429
567
  with: '<project>',
568
+ machine: true,
430
569
  });
431
570
  }
432
571
  if (where.home) {
@@ -438,6 +577,7 @@ export function machineRules(where) {
438
577
  wouldHide: 'The username, where a product shows it deliberately.',
439
578
  pattern: literal(where.home),
440
579
  with: '<home>',
580
+ machine: true,
441
581
  });
442
582
  }
443
583
  if (where.tmp) {
@@ -449,6 +589,7 @@ export function machineRules(where) {
449
589
  wouldHide: 'Nothing the product decides.',
450
590
  pattern: literal(where.tmp),
451
591
  with: '<temp>',
592
+ machine: true,
452
593
  });
453
594
  }
454
595
  return rules;
@@ -501,7 +642,11 @@ const rxCache = new Map();
501
642
  * @returns {RegExp}
502
643
  */
503
644
  function regexFor(pattern, flags) {
504
- const key = `${flags}${pattern}`;
645
+ // Written as the escape, never as the byte itself. A raw NUL in a source file makes grep,
646
+ // ripgrep and file(1) call the whole file binary and skip it in silence, so every audit that
647
+ // searches this tree stops seeing this module. That is how a dead export in here survived a
648
+ // sweep that was looking straight at it.
649
+ const key = `${flags}\u0000${pattern}`;
505
650
  let rx = rxCache.get(key);
506
651
  if (!rx) {
507
652
  rx = new RegExp(pattern, flags);
@@ -625,6 +770,7 @@ export function normaliseCapture(capture, rules) {
625
770
  ...capture,
626
771
  observations: capture.observations.map((o) => normaliseObservation(o, rules)),
627
772
  rules: rulesFingerprint(rules),
773
+ rulesScope: rulesScope(rules),
628
774
  };
629
775
  }
630
776
 
@@ -45,21 +45,6 @@ export const CHANNELS = [
45
45
  'pixels',
46
46
  ];
47
47
 
48
- /**
49
- * What each channel is, in the words we would use to a person. Printed by `doctor` and handed
50
- * to any agent that asks the tool to describe itself.
51
- * @type {Record<Channel, string>}
52
- */
53
- export const CHANNEL_NOTES = {
54
- meaning: 'What the interface says a control is and does — its role, its name, whether it is on, off or disabled. Not the underlying markup.',
55
- effects: 'What the product sent out into the world: calls made, files written, processes started, things saved.',
56
- complaints: 'What the product complained about: console messages, errors, crashes, the code it exited with.',
57
- results: 'What the product gave back: what it printed, what it answered, what it offers other code.',
58
- contract: 'The doors the source code says exist: routes, exported functions, message channels. Read without running anything.',
59
- counters: 'Rough counts and rough timings. Deliberately rough — precise timing is noise, not information.',
60
- pixels: 'What it looked like. Used to show a person a problem another channel already found.',
61
- };
62
-
63
48
  /**
64
49
  * @param {unknown} value
65
50
  * @returns {value is Channel}
@@ -78,17 +63,6 @@ const MAX_PATH_LENGTH = 512;
78
63
  /** Deepest value we will store. Past this something is recursing, not observing. */
79
64
  const MAX_VALUE_DEPTH = 64;
80
65
 
81
- /**
82
- * The share of its own addresses a build may disagree with itself about before the run stops
83
- * counting as a measurement at all. Half is not a tuned number and nothing depends on its
84
- * exact value: it is the point past which more of the comparison has been thrown away than
85
- * kept, and no answer computed from what is left deserves to be called clean.
86
- */
87
- const STORM_SHARE = 0.5;
88
-
89
- /** Below this many addresses the share means nothing — three out of four is not a storm. */
90
- const STORM_FLOOR = 12;
91
-
92
66
  /** Control characters and newlines, which would break the store and every log line. */
93
67
  const CONTROL_CHARS = /[\u0000-\u001f\u007f]/;
94
68
 
@@ -863,13 +837,25 @@ export function wobbleStorm(wobble) {
863
837
  const looked = unstable + wobble.steady;
864
838
  const vanished = wobble.entries.filter((e) => e.kind === 'vanished').length;
865
839
  const share = looked === 0 ? 0 : unstable / looked;
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))) {
840
+ // MORE OF IT WOBBLED THAN HELD STILL, and that one comparison is the whole rule. There is
841
+ // no threshold here to tune and no number to defend: half is the point past which more of
842
+ // the comparison has been thrown away than kept, and no answer computed from what is left
843
+ // deserves to be called clean.
844
+ //
845
+ // There used to be a floor under it: below twelve addresses the share was ignored
846
+ // altogether, with one exception carved out for the case where nothing at all held still.
847
+ // The floor was put there so that three-out-of-four would not be called a storm — and
848
+ // three of four IS one. It leaves a single address standing, and a run that compared one
849
+ // address is not entitled to the word clean. What the floor actually let through was worse
850
+ // than the thing it was guarding against: ten of eleven addresses wobbling was read as too
851
+ // small a sample to judge, so all ten differences were subtracted and the run came back
852
+ // saying nothing that already worked had changed. Confident, clean, and about one address.
853
+ //
854
+ // Counting rather than thresholding also swallows the zero-steady case whole. Nothing held
855
+ // still means everything wobbled, which is already more than half, so the exception that
856
+ // used to be written out separately is now just the arithmetic. One rule, no floor, no
857
+ // exception, and no number anybody has to defend.
858
+ if (!wobble.measured || unstable <= wobble.steady) {
873
859
  return { stormy: false, share, looked, vanished, why: '' };
874
860
  }
875
861
  const percent = Math.round(share * 100);