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
@@ -98,7 +98,14 @@ import { listBuilds, listCaptures, loadCapture, referencePointer } from './store
98
98
  * @property {string} [buildId]
99
99
  * @property {string[]} paths Non-contract observation addresses, and every
100
100
  * prefix of each, so a lookup is one set hit.
101
- * @property {string[]} [doors] Door keys the journey's steps name. See doorKey.
101
+ * @property {string[]} [doors] Door keys the journey's steps name, for steps that
102
+ * named a door with nothing to tell apart from
103
+ * another of the same name. See doorKey.
104
+ * @property {string[]} [doorAddresses] Full door addresses, for steps that were specific
105
+ * enough to build one. A route step knows its verb,
106
+ * and GET /x and POST /x are two doors that share a
107
+ * doorKey — so a step that knows which one it knocked
108
+ * on lands here instead, and the other stays shut.
102
109
  * @property {string[]} [touchedFiles]
103
110
  * @property {string[]} [touchedFunctions] 'file:name', from the suite's own coverage.
104
111
  * @property {number} [functionsNotListed] Functions that ran and were cut from the list to
@@ -412,9 +419,21 @@ export function walkFromCapture(capture, journey) {
412
419
  paths: touched.paths,
413
420
  };
414
421
  if (journey?.steps) {
415
- walk.doors = journey.steps
416
- .filter((s) => typeof s.door === 'string' && typeof s.kind === 'string')
417
- .map((s) => doorKey({ kind: String(s.kind), name: String(s.door) }));
422
+ // Two lists, because a doorKey is kind and name only. That is right for an IPC channel or
423
+ // an exported name, where the name IS the door; it is wrong for a route, where GET /basket
424
+ // and POST /basket share a key and are two different doors. A step that knows which one it
425
+ // knocked on says so with `doorDetail`, and goes in the exact list — otherwise walking GET
426
+ // would report POST as walked too, which is the coverage ledger lying in the one direction
427
+ // it must never lie in.
428
+ const named = journey.steps.filter((s) => typeof s.door === 'string' && typeof s.kind === 'string');
429
+ const exact = named.filter((s) => typeof s.doorDetail === 'string' && s.doorDetail !== '');
430
+ const byName = named.filter((s) => typeof s.doorDetail !== 'string' || s.doorDetail === '');
431
+ if (byName.length > 0) walk.doors = byName.map((s) => doorKey({ kind: String(s.kind), name: String(s.door) }));
432
+ if (exact.length > 0) {
433
+ walk.doorAddresses = exact.map((s) =>
434
+ doorAddress({ kind: String(s.kind), name: String(s.door), detail: String(s.doorDetail), file: typeof s.doorFile === 'string' ? s.doorFile : undefined }),
435
+ );
436
+ }
418
437
  }
419
438
  if (journey?.touched?.files) walk.touchedFiles = journey.touched.files;
420
439
  if (journey?.touched?.functions) walk.touchedFunctions = journey.touched.functions;
@@ -442,7 +461,7 @@ const ADDRESS_RULE = new Set(['ipc', 'route', 'export', 'env']);
442
461
  * @returns {{state: 'opened'|'reached', how: string}|null}
443
462
  */
444
463
  export function whatTheWalkDid(door, walk, paths) {
445
- if (walk.doors?.includes(doorKey(door))) {
464
+ if (walk.doorAddresses?.includes(door.address) || walk.doors?.includes(doorKey(door))) {
446
465
  return { state: 'opened', how: `"${walk.journey}" has a step that knocks on it directly.` };
447
466
  }
448
467
  if (ADDRESS_RULE.has(door.kind)) {
@@ -783,172 +802,6 @@ export async function ledger(store, product, opts = {}) {
783
802
  });
784
803
  }
785
804
 
786
- /**
787
- * The same picture, for one run.
788
- *
789
- * A verdict does not carry its observations, so on its own this can only report totals — and
790
- * it says `knows: 'counts only'` rather than pretending to a per-door answer it does not
791
- * have. Hand it the doors and the walks from that run and it upgrades to the full ledger.
792
- *
793
- * @param {Verdict} verdict
794
- * @param {{doors?: (Door|DoorFact)[], walks?: Walk[]}} [opts]
795
- * @returns {Ledger}
796
- */
797
- export function coverageOf(verdict, opts = {}) {
798
- /** @type {Coverage} */
799
- const coverage = verdict.coverage ?? { paths: 0, journeys: 0, byChannel: {}, gaps: [] };
800
- if (opts.doors && opts.walks) {
801
- const doors = opts.doors.map((d) => ('address' in d ? d : doorFact(d)));
802
- return buildLedger({
803
- product: verdict.product,
804
- doors,
805
- walks: opts.walks,
806
- byChannel: coverage.byChannel,
807
- captures: opts.walks.length,
808
- builds: 1,
809
- at: verdict.startedAt,
810
- caveats: [`This is one run — ${nameRun(verdict)} — not everything this tool has ever walked.`],
811
- gaps: coverage.gaps ?? [],
812
- });
813
- }
814
-
815
- const doors = coverage.doorsKnown ?? 0;
816
- const opened = coverage.doorsWalked ?? 0;
817
- /** @type {string[]} */
818
- const caveats = [
819
- `This is one run — ${nameRun(verdict)} — not everything this tool has ever walked.`,
820
- 'A verdict carries totals rather than addresses, so this cannot name which doors were left shut. Call ledger(store, product) for that.',
821
- ];
822
- if (verdict.mode === 'stored-record') {
823
- caveats.push(
824
- verdict.modeWarning
825
- ?? 'The old build was not booted. This run was compared against observations stored the last time it ran, which lets back in every difference that comes from the day being different.',
826
- );
827
- }
828
- if (doors === 0) {
829
- caveats.push('Nothing counted the doors on this run, so there is no denominator, and "nothing changed" here means only "nothing I looked at changed".');
830
- }
831
-
832
- return {
833
- product: verdict.product,
834
- at: verdict.startedAt,
835
- knows: 'counts only',
836
- doors,
837
- opened,
838
- reached: 0,
839
- never: Math.max(0, doors - opened),
840
- unwalkable: 0,
841
- work: Math.max(0, doors - opened),
842
- irreversible: 0,
843
- entries: [],
844
- byKind: {},
845
- journeys: coverage.journeys ?? 0,
846
- byJourneySource: {},
847
- byChannel: coverage.byChannel ?? {},
848
- captures: coverage.journeys ?? 0,
849
- builds: 1,
850
- caveats,
851
- gaps: coverage.gaps ?? [],
852
- };
853
- }
854
-
855
- /**
856
- * @param {Verdict} verdict
857
- * @returns {string}
858
- */
859
- function nameRun(verdict) {
860
- const candidate = verdict.candidate?.version || verdict.candidate?.id || 'this build';
861
- return `${candidate}, ${verdict.mode === 'paired' ? 'against the old build booted live' : 'against the stored record'}`;
862
- }
863
-
864
- // ---------------------------------------------------------------------------
865
- // Saying it out loud
866
- // ---------------------------------------------------------------------------
867
-
868
- /**
869
- * The ledger in plain English, honest and specific, one line each.
870
- *
871
- * The headline is the number nobody wants to publish, and it goes first on purpose:
872
- * "452 doors, 61 opened, 391 never opened. A clean result says nothing about those 391."
873
- *
874
- * There is no percentage anywhere in here, and that is not an oversight. A percentage
875
- * invites a target, a target invites gaming, and a gamed coverage number is worse than none
876
- * because somebody believes it.
877
- *
878
- * @param {Ledger} led
879
- * @returns {string[]} join with a space for a paragraph, or a newline for a list
880
- */
881
- export function describeCoverage(led) {
882
- /** @type {string[]} */
883
- const lines = [];
884
-
885
- if (led.doors === 0) {
886
- lines.push('Nothing here knows how many doors this product has, so there is no honest way to say how much of it was checked.');
887
- } else {
888
- const parts = [count(led.doors, 'door'), `${led.opened} opened`];
889
- if (led.reached > 0) parts.push(`${led.reached} in code that ran but never addressed`);
890
- parts.push(`${led.never} never opened`);
891
- lines.push(`${parts.join(', ')}.`);
892
- if (led.never > 0) lines.push(`A clean result says nothing about those ${led.never}.`);
893
- }
894
-
895
- if (led.unwalkable > 0) {
896
- const permanent = led.irreversible > 0
897
- ? `${led.irreversible} that would spend money, send a message or destroy something and are stopped at the call on purpose`
898
- : 'doors there is nothing to knock on';
899
- lines.push(
900
- `Of those ${led.never}, ${led.unwalkable} can never be opened from here — settings that are read rather than called, names built while the program runs, and ${permanent}. That leaves ${led.work} that could be covered and are not.`,
901
- );
902
- } else if (led.work > 0 && led.doors > 0) {
903
- lines.push(`All ${led.work} of the unopened ones could be covered.`);
904
- }
905
-
906
- // Every kind, not the worst four. There are five kinds of door in the whole tool, so
907
- // cutting the list saved one line and dropped a whole category of the product out of the
908
- // only sentence that says how much of it is covered.
909
- const kinds = Object.entries(led.byKind)
910
- .filter(([, k]) => k.doors > 0)
911
- .sort((a, b) => b[1].never - a[1].never)
912
- .map(([kind, k]) => `${k.opened} of ${k.doors} ${k.doors === 1 ? KIND_ONE[kind] ?? kind : KIND_MANY[kind] ?? kind}`);
913
- if (kinds.length > 0) lines.push(`By kind: ${kinds.join(', ')}.`);
914
-
915
- if (led.journeys === 0) {
916
- lines.push('No journey has ever been walked against this product, so nothing here rests on anything.');
917
- } else {
918
- const sources = Object.entries(led.byJourneySource)
919
- .filter(([, n]) => n > 0)
920
- .sort((a, b) => b[1] - a[1])
921
- .map(([source, n]) => `${n} ${SOURCE_PHRASE[source] ?? source}`);
922
- lines.push(
923
- `${count(led.journeys, 'journey')} produced ${count(led.captures, 'capture')}${sources.length > 0 ? ` — ${sources.join(', ')}` : ''}.`,
924
- );
925
- }
926
-
927
- for (const caveat of led.caveats) lines.push(caveat);
928
- if (led.gaps.length > 0) {
929
- lines.push(`${count(led.gaps.length, 'other thing')} could not be looked at, and each one says what would fix it.`);
930
- }
931
- return lines;
932
- }
933
-
934
- /** @type {Record<string, string>} */
935
- const SOURCE_PHRASE = {
936
- code: 'read out of the code',
937
- suite: "harvested from the project's own tests",
938
- recorded: 'recorded from a real session',
939
- explored: 'found by an agent exploring',
940
- unknown: 'of unrecorded origin',
941
- };
942
-
943
- /**
944
- * @param {number} n
945
- * @param {string} noun
946
- * @returns {string}
947
- */
948
- function count(n, noun) {
949
- return `${n} ${noun}${n === 1 ? '' : 's'}`;
950
- }
951
-
952
805
  // ---------------------------------------------------------------------------
953
806
  // The work queue
954
807
  // ---------------------------------------------------------------------------
@@ -958,7 +811,7 @@ function count(n, noun) {
958
811
  * @property {number} [worst] How many jobs to hand back. Default 12.
959
812
  * @property {boolean} [includeUnwalkable] Include doors nothing here could ever open. Off:
960
813
  * they belong in the honest total, not in a queue,
961
- * and describeCoverage names them anyway.
814
+ * and the ledger counts them either way.
962
815
  * @property {number} [minDoors] Ignore families smaller than this. Default 1.
963
816
  */
964
817
 
@@ -1108,11 +961,25 @@ export function toCoverage(led, opts = {}) {
1108
961
  const out = [...led.gaps];
1109
962
  if (led.doors > 0 && led.never > 0) {
1110
963
  out.push({
1111
- what: `${led.never} of this product's ${led.doors} doors have never been opened by this tool.`,
1112
- why: 'No journey reaches them, so a break behind one of them would not show up in any run — clean or otherwise.',
964
+ // A product with one door read "1 of this product's 1 doors have never been opened",
965
+ // which is the sentence a reader stops believing the rest of the report over. The count
966
+ // is the whole point of the line, so it is worth the four words it costs to say it in
967
+ // English.
968
+ what: led.doors === 1
969
+ ? "This product's only door has never been opened by this tool."
970
+ : led.never === 1
971
+ ? `1 of this product's ${led.doors} doors has never been opened by this tool.`
972
+ : `${led.never} of this product's ${led.doors} doors have never been opened by this tool.`,
973
+ why: led.never === 1
974
+ ? 'No journey reaches it, so a break behind it would not show up in any run — clean or otherwise.'
975
+ : 'No journey reaches them, so a break behind one of them would not show up in any run — clean or otherwise.',
1113
976
  unlockedBy: led.work > 0
1114
- ? `${led.work} of them could be covered by journeys that reach them — read out of your source, or named by hand in a journeys file.`
1115
- : 'Nothing. Every one of them is a door this tool cannot open from here, and each says why.',
977
+ ? led.work === 1
978
+ ? 'It could be covered by a journey that reaches it read out of your source, or named by hand in a journeys file.'
979
+ : `${led.work} of them could be covered by journeys that reach them — read out of your source, or named by hand in a journeys file.`
980
+ : led.never === 1
981
+ ? 'Nothing. It is a door this tool cannot open from here, and it says why.'
982
+ : 'Nothing. Every one of them is a door this tool cannot open from here, and each says why.',
1116
983
  channel: 'contract',
1117
984
  doors: led.never,
1118
985
  });