staysfixed 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +182 -0
  2. package/README.md +17 -5
  3. package/docs/getting-started.md +10 -0
  4. package/docs/how-v2-works.md +5 -2
  5. package/package.json +2 -2
  6. package/src/guard/api.js +107 -3
  7. package/src/guard/run.js +154 -20
  8. package/src/report/console.js +235 -17
  9. package/src/report/html.js +75 -19
  10. package/src/types.js +5 -0
  11. package/src/v2/adapters/android-driver.js +62 -12
  12. package/src/v2/adapters/contract.js +18 -4
  13. package/src/v2/adapters/electron.js +96 -14
  14. package/src/v2/adapters/http.js +264 -23
  15. package/src/v2/adapters/ios-driver.js +22 -4
  16. package/src/v2/adapters/ios.js +5 -2
  17. package/src/v2/adapters/isolate.js +78 -5
  18. package/src/v2/adapters/process.js +350 -92
  19. package/src/v2/adapters/web-driver.js +23 -1
  20. package/src/v2/adapters/web.js +42 -3
  21. package/src/v2/adapters/windows.js +32 -15
  22. package/src/v2/check.js +526 -19
  23. package/src/v2/cli.js +345 -3
  24. package/src/v2/cluster.js +112 -4
  25. package/src/v2/coverage.js +293 -8
  26. package/src/v2/detect.js +182 -9
  27. package/src/v2/doctor.js +253 -30
  28. package/src/v2/init.js +102 -10
  29. package/src/v2/mcp/server.js +4 -1
  30. package/src/v2/mcp/tools.js +291 -24
  31. package/src/v2/normalise.js +11 -0
  32. package/src/v2/observation.js +57 -5
  33. package/src/v2/reference.js +133 -14
  34. package/src/v2/refusal.js +389 -0
  35. package/src/v2/remote.js +24 -3
  36. package/src/v2/run.js +306 -16
  37. package/src/v2/sealed.js +14 -2
  38. package/src/v2/ship.js +286 -22
  39. package/src/v2/store.js +101 -2
  40. package/src/v2/types.js +5 -0
  41. package/src/v2/waiver.js +9 -2
  42. package/src/watch/panel.js +12 -1
@@ -48,7 +48,7 @@
48
48
  import { asAddress } from './adapters/electron.js';
49
49
  import { readContract, readFileRoutes, readPackageCommands } from './adapters/source.js';
50
50
  import { familyOf, irreversibility, isRunnable } from './journeys/from-routes.js';
51
- import { joinPath, splitPath } from './observation.js';
51
+ import { CHANNELS, joinPath, splitPath } from './observation.js';
52
52
  import { listBuilds, listCaptures, loadCapture, referencePointer } from './store.js';
53
53
 
54
54
  /** @typedef {import('./types.js').Channel} Channel */
@@ -101,6 +101,18 @@ import { listBuilds, listCaptures, loadCapture, referencePointer } from './store
101
101
  * @property {string[]} [doors] Door keys the journey's steps name, for steps that
102
102
  * named a door with nothing to tell apart from
103
103
  * another of the same name. See doorKey.
104
+ * @property {{door: string, status: number}[]} [onlyRedirected]
105
+ * Doors that answered with a redirect. Walked — the
106
+ * bounce is real behaviour — but what is behind them
107
+ * was never seen.
108
+ * @property {{door: string, status: number}[]} [knockedShut]
109
+ * Doors a step knocked on where the running build
110
+ * answered that they are not there. Knocking is not
111
+ * walking, and these have proved nothing.
112
+ * @property {string[]} [notTried] Doors this journey's steps name, on a walk that
113
+ * never happened — the adapter refused it and wrote
114
+ * down only that. Nobody knocked, so nothing here
115
+ * is walked.
104
116
  * @property {string[]} [doorAddresses] Full door addresses, for steps that were specific
105
117
  * enough to build one. A route step knows its verb,
106
118
  * and GET /x and POST /x are two doors that share a
@@ -384,6 +396,14 @@ function asDoor(door) {
384
396
  * file: reading a door out of the source is how we know it exists, never evidence that
385
397
  * anybody opened it.
386
398
  *
399
+ * A refusal is dropped for the same reason. When an adapter cannot look at something it
400
+ * writes that down at the address it would have looked at, marked `refused` — a payment it
401
+ * would not make, a server that never started, a route with a parameter nobody supplied.
402
+ * Left in, the note saying "we did not look here" became the evidence that we did.
403
+ *
404
+ * Both still count in `byChannel`: they were written down, and the tallies say how much was
405
+ * written down. It is only the "somebody opened this door" set they are kept out of.
406
+ *
387
407
  * @param {Observation[]} observations
388
408
  * @returns {{paths: string[], byChannel: Partial<Record<Channel, number>>}}
389
409
  */
@@ -395,12 +415,22 @@ export function addressesTouched(observations) {
395
415
  for (const o of observations) {
396
416
  byChannel[o.channel] = (byChannel[o.channel] ?? 0) + 1;
397
417
  if (o.channel === 'contract') continue;
418
+ if (o.meta?.refused === true) continue;
398
419
  const parts = String(o.path).split('.');
399
420
  for (let i = 1; i <= parts.length; i++) paths.add(parts.slice(0, i).join('.'));
400
421
  }
401
422
  return { paths: [...paths], byChannel };
402
423
  }
403
424
 
425
+ /**
426
+ * Answers that mean the door is not there in the build that ran.
427
+ *
428
+ * A 500 is deliberately NOT here: the route exists and it broke, which is a real difference
429
+ * and exactly what a check is for. These four are the codes that say the thing the source
430
+ * declares was never reachable, so nothing has been proved about it either way.
431
+ */
432
+ const NOTHING_THERE = new Set([404, 405, 410, 501]);
433
+
404
434
  /**
405
435
  * A walk, built from one stored capture and, when it is to hand, the journey behind it.
406
436
  *
@@ -418,6 +448,26 @@ export function walkFromCapture(capture, journey) {
418
448
  buildId: capture.build?.id,
419
449
  paths: touched.paths,
420
450
  };
451
+ // NOTHING WAS TRIED IS NOT A WALK.
452
+ //
453
+ // Every adapter has branches where it runs nothing and says so: the server never came up,
454
+ // the route has a `:id` nobody supplied a value for, the command spends money and there is
455
+ // nothing watching to stop it. Each one writes a single observation marked `refused` and
456
+ // returns. No `api.<door>.status` comes back, because no request went out.
457
+ //
458
+ // The status rule below reads a missing status as "it answered something we are happy
459
+ // with", so every one of those doors counted as walked. Measured 2026-08-31 with the HTTP
460
+ // adapter's own output: one route, one journey, the server never started, and the ledger
461
+ // came back doorsWalked 1 of 1 — full coverage of a product that had not been run — in the
462
+ // same report whose only line read "was not tried: It never started."
463
+ //
464
+ // A capture whose entire non-contract record is refusals is a walk that did nothing. Its
465
+ // steps opened nothing, and the doors they name are listed rather than dropped.
466
+ const seen = capture.observations ?? [];
467
+ const refusals = seen.filter((o) => o?.meta?.refused === true);
468
+ const anythingHappened = seen.some((o) => o?.channel !== 'contract' && o?.meta?.refused !== true);
469
+ const nothingWasTried = refusals.length > 0 && !anythingHappened;
470
+
421
471
  if (journey?.steps) {
422
472
  // Two lists, because a doorKey is kind and name only. That is right for an IPC channel or
423
473
  // an exported name, where the name IS the door; it is wrong for a route, where GET /basket
@@ -426,8 +476,62 @@ export function walkFromCapture(capture, journey) {
426
476
  // would report POST as walked too, which is the coverage ledger lying in the one direction
427
477
  // it must never lie in.
428
478
  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 === '');
479
+
480
+ // KNOCKING IS NOT WALKING.
481
+ //
482
+ // A door was counted as walked because a STEP said it knocked on it — whatever came back.
483
+ // So a route the source declares and the running build answers 404 counted as covered; and
484
+ // behind a login wall, where every request is bounced to /login, every door in the product
485
+ // counted as walked and the run came back clean. That is the coverage ledger lying in the
486
+ // one direction this file says it must never lie in.
487
+ //
488
+ // What answered is on the record: the http adapter writes `api.<door>.status`. A 404, 405,
489
+ // 410 or 501 means the thing the code declares is not there in the build that ran, so
490
+ // nothing was proved about it and it stays shut.
491
+ const answered = new Map();
492
+ for (const ob of capture.observations ?? []) {
493
+ const found = /^api\.(.+)\.status$/.exec(String(ob?.path ?? ''));
494
+ if (found) answered.set(found[1], Number(ob.value));
495
+ }
496
+ /** @type {{door: string, status: number}[]} */
497
+ const shut = [];
498
+ /** @type {{door: string, status: number}[]} */
499
+ const bounced = [];
500
+ /** @type {string[]} */
501
+ const untried = [];
502
+ /** @param {any} s @returns {boolean} */
503
+ const reallyWalked = (s) => {
504
+ // The door is the ROUTE (`/reports`); the observation is addressed by method and route
505
+ // together (`api.GET /reports.status`), because GET and POST on one path are two doors.
506
+ // Try the composite first and the bare name after, so both shapes of step are covered.
507
+ const keys = [
508
+ typeof s.doorDetail === 'string' && s.doorDetail ? `${s.doorDetail} ${s.door}` : null,
509
+ typeof s.method === 'string' && s.method ? `${s.method} ${s.door}` : null,
510
+ String(s.door),
511
+ ].filter(Boolean);
512
+ if (nothingWasTried) {
513
+ untried.push(String(keys[0] ?? s.door));
514
+ return false;
515
+ }
516
+ const key = keys.find((k) => answered.has(/** @type {string} */ (k)));
517
+ const code = key === undefined ? undefined : answered.get(/** @type {string} */ (key));
518
+ // A redirect is real behaviour and it IS walked — but what you saw is the bounce, not the
519
+ // thing behind it. Behind a login wall every door answers 302 to /login, and the run
520
+ // then reports full coverage of a product it never got into.
521
+ if (typeof code === 'number' && code >= 300 && code < 400) {
522
+ bounced.push({ door: String(key), status: code });
523
+ return true;
524
+ }
525
+ if (typeof code !== 'number' || !NOTHING_THERE.has(code)) return true;
526
+ shut.push({ door: String(key), status: code });
527
+ return false;
528
+ };
529
+
530
+ const exact = named.filter((s) => typeof s.doorDetail === 'string' && s.doorDetail !== '').filter(reallyWalked);
531
+ const byName = named.filter((s) => typeof s.doorDetail !== 'string' || s.doorDetail === '').filter(reallyWalked);
532
+ if (shut.length > 0) walk.knockedShut = shut;
533
+ if (bounced.length > 0) walk.onlyRedirected = bounced;
534
+ if (untried.length > 0) walk.notTried = untried;
431
535
  if (byName.length > 0) walk.doors = byName.map((s) => doorKey({ kind: String(s.kind), name: String(s.door) }));
432
536
  if (exact.length > 0) {
433
537
  walk.doorAddresses = exact.map((s) =>
@@ -497,6 +601,138 @@ function trimmedAddress(door) {
497
601
  return joinPath(['ipc', asAddress(door.name)]);
498
602
  }
499
603
 
604
+ // ---------------------------------------------------------------------------
605
+ // One order, every time
606
+ // ---------------------------------------------------------------------------
607
+
608
+ /**
609
+ * TWO IDENTICAL RUNS HAVE TO SAY THE SAME THING.
610
+ *
611
+ * This tool's whole method is running the same thing twice and subtracting what disagrees.
612
+ * A report that disagrees with itself between two identical runs is not untidy — it is the
613
+ * measurement contradicting the method, and a reader who spots it has no reason to believe
614
+ * the rest.
615
+ *
616
+ * It was doing exactly that. A capture id ends in three random bytes, deliberately, so two
617
+ * captures written inside the same second do not overwrite each other — and the store hands
618
+ * captures back in id order, so those random bytes decide which walk is read first. Measured
619
+ * 2026-08-31 on one unchanged product, three runs: help, the-code, the-code, help; then
620
+ * the-code, help, the-code, help; then help, the-code, the-code, help.
621
+ *
622
+ * Everything the ledger built in that order came out shuffled with it — which of two
623
+ * journeys got named as the evidence for a door, which six of eight shut doors got listed by
624
+ * name in the caveat, the order of the gaps, the key order of the tallies. So the walks are
625
+ * put in an order of the ledger's own before anything reads them, and that order is a
626
+ * function of what is IN each walk, never of when it arrived.
627
+ *
628
+ * @param {Walk[]} walks
629
+ * @returns {Walk[]}
630
+ */
631
+ function inWalkOrder(walks) {
632
+ return [...walks]
633
+ .map((walk) => ({ walk, key: walkOrderKey(walk) }))
634
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
635
+ .map(({ walk }) => walk);
636
+ }
637
+
638
+ /**
639
+ * The whole of one walk, squeezed into a single sortable string.
640
+ *
641
+ * Every field that can reach the report is in here, so two walks only ever tie when they
642
+ * would produce the same words either way round. The addresses are the exception: a walk on
643
+ * a big product carries tens of thousands of them, and holding all of that in a sort key
644
+ * costs more than the ordering is worth. How many there are, plus a fingerprint of them,
645
+ * tells two walks apart just as well and stays one short string.
646
+ *
647
+ * @param {Walk} walk
648
+ * @returns {string}
649
+ */
650
+ function walkOrderKey(walk) {
651
+ return [
652
+ walk.journey ?? '',
653
+ walk.at ?? '',
654
+ walk.buildId ?? '',
655
+ walk.source ?? '',
656
+ String((walk.paths ?? []).length),
657
+ digestOf(walk.paths ?? []),
658
+ (walk.doors ?? []).join('\u0001'),
659
+ (walk.doorAddresses ?? []).join('\u0001'),
660
+ (walk.knockedShut ?? []).map((d) => `${d.door}=${d.status}`).join('\u0001'),
661
+ (walk.onlyRedirected ?? []).map((d) => `${d.door}=${d.status}`).join('\u0001'),
662
+ (walk.notTried ?? []).join('\u0001'),
663
+ (walk.touchedFiles ?? []).join('\u0001'),
664
+ (walk.touchedFunctions ?? []).join('\u0001'),
665
+ String(walk.functionsNotListed ?? 0),
666
+ ].join('\u0000');
667
+ }
668
+
669
+ /**
670
+ * A short fingerprint of a list of strings. FNV-1a, written out rather than imported,
671
+ * because this file has no business opening a crypto library to decide a sort order.
672
+ *
673
+ * @param {string[]} items
674
+ * @returns {string}
675
+ */
676
+ function digestOf(items) {
677
+ let hash = 0x811c9dc5;
678
+ for (const item of items) {
679
+ for (let i = 0; i < item.length; i++) {
680
+ hash ^= item.charCodeAt(i);
681
+ hash = Math.imul(hash, 0x01000193) >>> 0;
682
+ }
683
+ hash ^= 0x0a;
684
+ hash = Math.imul(hash, 0x01000193) >>> 0;
685
+ }
686
+ return hash.toString(16).padStart(8, '0');
687
+ }
688
+
689
+ /**
690
+ * Sort a list of pairs by the first of the two. Used wherever a caveat names only the first
691
+ * few of something: which few get named has to be decided by their names.
692
+ *
693
+ * @param {[string, unknown]} a
694
+ * @param {[string, unknown]} b
695
+ * @returns {number}
696
+ */
697
+ function byFirst(a, b) {
698
+ return a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0;
699
+ }
700
+
701
+ /**
702
+ * The channel tallies written out in one fixed order — the channel list's own, not the order
703
+ * the observations happened to arrive in. Same reason as {@link inWalkOrder}: this object is
704
+ * printed by `--json`, and JSON keeps the order keys were added in.
705
+ *
706
+ * @param {Partial<Record<Channel, number>>} byChannel
707
+ * @returns {Partial<Record<Channel, number>>}
708
+ */
709
+ function inChannelOrder(byChannel) {
710
+ /** @type {Record<string, number>} */
711
+ const out = {};
712
+ const held = /** @type {Record<string, number>} */ (byChannel);
713
+ for (const channel of CHANNELS) if (held[channel] !== undefined) out[channel] = held[channel];
714
+ // A channel this build of the tool has never heard of is still evidence somebody wrote
715
+ // down, and dropping it would make the report smaller than the run was.
716
+ for (const channel of Object.keys(held).sort()) if (out[channel] === undefined) out[channel] = held[channel];
717
+ return out;
718
+ }
719
+
720
+ /**
721
+ * Sort gaps into an order of their own. Only for gaps whose order carries no meaning — the
722
+ * ones read back out of stored captures, which arrive in whatever order the disk listed
723
+ * them. The gaps `toCoverage` builds are in rank order and are left exactly as they are.
724
+ *
725
+ * @param {CoverageGap[]} list
726
+ * @returns {CoverageGap[]}
727
+ */
728
+ function inGapOrder(list) {
729
+ return [...list].sort((a, b) => {
730
+ const left = `${a.what}\u0000${a.why}\u0000${a.unlockedBy ?? ''}`;
731
+ const right = `${b.what}\u0000${b.why}\u0000${b.unlockedBy ?? ''}`;
732
+ return left < right ? -1 : left > right ? 1 : 0;
733
+ });
734
+ }
735
+
500
736
  // ---------------------------------------------------------------------------
501
737
  // The ledger
502
738
  // ---------------------------------------------------------------------------
@@ -525,7 +761,7 @@ function trimmedAddress(door) {
525
761
  export function buildLedger(input) {
526
762
  /** @type {DoorEntry[]} */
527
763
  const entries = [];
528
- const walks = input.walks.map((walk) => ({ walk, paths: new Set(walk.paths) }));
764
+ const walks = inWalkOrder(input.walks).map((walk) => ({ walk, paths: new Set(walk.paths) }));
529
765
 
530
766
  /** @type {Record<string, KindTally>} */
531
767
  const byKind = {};
@@ -613,6 +849,42 @@ export function buildLedger(input) {
613
849
  `${cutFunctions} functions that really did run were cut from the coverage lists to keep them readable, so up to that many of the doors counted as never opened were in fact opened. This ledger undercounts, and it undercounts by no more than ${cutFunctions}.`,
614
850
  );
615
851
  }
852
+ // Named, never silently dropped. A route the code declares and the build answers 404 to is
853
+ // not a covered route and it is not an absent one either — it is a disagreement between the
854
+ // source and the thing that ran, and that is worth more than most differences.
855
+ /** @type {Map<string, number>} */
856
+ const shutDoors = new Map();
857
+ for (const { walk } of walks) for (const d of walk.knockedShut ?? []) shutDoors.set(d.door, d.status);
858
+ if (shutDoors.size > 0) {
859
+ // Sorted, so which six get named is decided by their names and not by which capture the
860
+ // disk happened to hand back first. See `inWalkOrder` for why that was ever in doubt.
861
+ const listed = [...shutDoors.entries()].sort(byFirst).slice(0, 6).map(([door, code]) => `${door} answered ${code}`).join(', ');
862
+ caveats.push(
863
+ `${shutDoors.size} ${shutDoors.size === 1 ? 'door the code declares was' : 'doors the code declares were'} knocked on and answered as not being there (${listed}${shutDoors.size > 6 ? ', and more' : ''}). Knocking is not walking: nothing has been proved about ${shutDoors.size === 1 ? 'it' : 'them'}, and the source and the build that ran disagree about whether ${shutDoors.size === 1 ? 'it exists' : 'they exist'}.`,
864
+ );
865
+ }
866
+ /** @type {Map<string, number>} */
867
+ const bouncedDoors = new Map();
868
+ for (const { walk } of walks) for (const d of walk.onlyRedirected ?? []) bouncedDoors.set(d.door, d.status);
869
+ if (bouncedDoors.size > 0) {
870
+ const all = bouncedDoors.size >= Math.max(1, opened);
871
+ caveats.push(
872
+ `${bouncedDoors.size} ${bouncedDoors.size === 1 ? 'door' : 'doors'} answered with a redirect rather than with ${bouncedDoors.size === 1 ? 'a page' : 'pages'} — ${[...bouncedDoors.entries()].sort(byFirst).slice(0, 5).map(([door, code]) => `${door} answered ${code}`).join(', ')}${bouncedDoors.size > 5 ? ', and more' : ''}. What was seen is the bounce, not what is behind it.${all ? ' EVERY door that answered did this, which is what a sign-in wall looks like from out here: this run has not been inside the product at all.' : ''}`,
873
+ );
874
+ }
875
+ // A door whose journey was refused before anything ran. Nobody knocked on it, so it is not
876
+ // shut and it is not bounced — it is untouched, and the only wrong answer is to leave it
877
+ // out. This says the number out loud so nobody has to notice a door that quietly stopped
878
+ // being counted as walked.
879
+ /** @type {Set<string>} */
880
+ const untriedDoors = new Set();
881
+ for (const { walk } of walks) for (const door of walk.notTried ?? []) untriedDoors.add(door);
882
+ if (untriedDoors.size > 0) {
883
+ const listed = [...untriedDoors].sort().slice(0, 6).join(', ');
884
+ caveats.push(
885
+ `${untriedDoors.size} ${untriedDoors.size === 1 ? 'door was' : 'doors were'} never tried at all (${listed}${untriedDoors.size > 6 ? ', and more' : ''}). The journey that would have opened ${untriedDoors.size === 1 ? 'it' : 'them'} was refused before anything ran — the thing it needed did not start, or a value it needed was never supplied — and the reason is on the record beside it. ${untriedDoors.size === 1 ? 'It is' : 'They are'} counted here as never opened, because nothing knocked.`,
886
+ );
887
+ }
616
888
  if (input.doors.length === 0) {
617
889
  caveats.push('No doors are known at all, so this ledger cannot say what is uncovered — which is not the same as there being nothing uncovered.');
618
890
  }
@@ -634,8 +906,11 @@ export function buildLedger(input) {
634
906
  entries,
635
907
  byKind,
636
908
  journeys: new Set(walks.map(({ walk }) => walk.journey)).size,
637
- byJourneySource,
638
- byChannel: input.byChannel ?? {},
909
+ // Both of these are printed by `--json`, and JSON keeps the order keys were added in.
910
+ // Added in the order the walks arrived, they came out shuffled between two identical
911
+ // runs — see `inWalkOrder`. Written out in an order of their own, they do not.
912
+ byJourneySource: Object.fromEntries(Object.entries(byJourneySource).sort(byFirst)),
913
+ byChannel: inChannelOrder(input.byChannel ?? {}),
639
914
  captures: input.captures ?? walks.length,
640
915
  builds: input.builds ?? 0,
641
916
  caveats,
@@ -798,7 +1073,11 @@ export async function ledger(store, product, opts = {}) {
798
1073
  captures,
799
1074
  builds: wanted.length,
800
1075
  caveats,
801
- gaps: dedupeGaps(holes),
1076
+ // These holes were collected build by build and capture by capture, in the order the
1077
+ // store listed them — which is the order the random end of a capture id put them in.
1078
+ // Nothing about one unreadable record makes it more urgent than another, so they go in
1079
+ // an order of their own and two identical runs list them the same way round.
1080
+ gaps: inGapOrder(dedupeGaps(holes)),
802
1081
  });
803
1082
  }
804
1083
 
@@ -890,7 +1169,13 @@ export function gaps(led, opts = {}) {
890
1169
  });
891
1170
  }
892
1171
 
893
- const ranked = jobs.sort((a, b) => (b.rank === a.rank ? b.doors - a.doors : b.rank - a.rank));
1172
+ // Whole families tie here routinely ten folders of four unopened exports each score the
1173
+ // same and hold the same number of doors — and a tie used to be settled by whichever came
1174
+ // out of the map first. The family's own name settles it instead, so the list this file
1175
+ // hands back is the same list every time and the cut at the end falls in the same place.
1176
+ const ranked = jobs.sort(
1177
+ (a, b) => b.rank - a.rank || b.doors - a.doors || (a.group < b.group ? -1 : a.group > b.group ? 1 : 0),
1178
+ );
894
1179
  if (ranked.length <= worst) return ranked;
895
1180
 
896
1181
  // The cut is real and it used to be invisible. `toCoverage` asks for eight jobs; a product