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
package/src/v2/detect.js CHANGED
@@ -140,6 +140,24 @@ const PLATFORM_FOLDERS = [
140
140
  /** How many files the artifact and test sweeps will look at before giving up and saying so. */
141
141
  const MOST_FILES = 20_000;
142
142
 
143
+ /**
144
+ * The folders a project's own code normally lives in. The same list the source reader falls
145
+ * back to, kept here so this file can say WHICH folders it asked for instead of leaving the
146
+ * reader to guess — see {@link whereTheCodeIs} for why that mattered.
147
+ */
148
+ const USUAL_SOURCE_FOLDERS = ['src', 'lib', 'app', 'bin', 'server', 'pages', 'api', 'electron', 'main', 'packages'];
149
+
150
+ /**
151
+ * Root-level files that are how a project is BUILT rather than what it ships. A packaging
152
+ * config sitting beside `src/` is no reason to read the whole repository; a `server.js`
153
+ * sitting beside `src/` is every reason.
154
+ */
155
+ const ROOT_TOOLING_FILES = new Set([
156
+ 'gulpfile.js', 'gulpfile.mjs', 'gulpfile.cjs', 'gruntfile.js', 'karma.conf.js',
157
+ 'protractor.conf.js', 'gatsby-config.js', 'gatsby-node.js', 'gatsby-browser.js',
158
+ 'gatsby-ssr.js', 'webpack.mix.js',
159
+ ]);
160
+
143
161
  /**
144
162
  * Folders that are never a product of their own: either the contract channel already read
145
163
  * them as part of the root product, or they hold work about the project rather than the
@@ -278,7 +296,11 @@ export async function detectProject(options = {}) {
278
296
  // The source read, once, for two answers — how many doors there are, and what the routes
279
297
  // are called. Reading Terminal Deck's 1,416 files twice because two functions each wanted
280
298
  // their own copy cost a second and a half of the two this whole detection takes.
281
- const reading = readCode ? await readTheSource(root) : { doors: notRead(), routes: [], channels: [], envNames: [] };
299
+ // The folders are named here rather than left to the reader's own default, because the
300
+ // default reads `src/` and its cousins and NOTHING at the top level — see
301
+ // {@link whereTheCodeIs} for the server that went unread because of it.
302
+ const firstFolders = whereTheCodeIs(listing);
303
+ const reading = readCode ? await readTheSource(root, firstFolders) : { doors: notRead(), routes: [], channels: [], envNames: [] };
282
304
  const doors = reading.doors;
283
305
  const routes = reading.routes;
284
306
  const channels = reading.channels;
@@ -323,7 +345,7 @@ export async function detectProject(options = {}) {
323
345
  root, where: place.root, listing: local, pkg: place.pkg,
324
346
  // Doors and pages were read from the root, so they only describe the root. A
325
347
  // sub-package gets credited with them only when it IS the root.
326
- doors: place.root === '.' ? doors : notRead(),
348
+ doors: place.root === '.' ? theRootsOwnDoors(doors, routes, members) : notRead(),
327
349
  pages: place.root === '.' ? pages : [],
328
350
  containers: place.root === '.' ? containers : { dockerfile: null, compose: null },
329
351
  scripts: place.pkg?.scripts ?? {},
@@ -354,6 +376,12 @@ export async function detectProject(options = {}) {
354
376
  for (const folder of listing.dirs) {
355
377
  if (SKIP_DIRS.has(folder) || folder.startsWith('.') || claimed.has(folder)) continue;
356
378
  if (members.some((m) => m.root === folder)) continue;
379
+ // Nor is a folder whose whole contents are already accounted for one level down. A
380
+ // monorepo's `apps/` is a shelf: every product in it was found, named and is being
381
+ // checked, and this warning would say the opposite in the plainest words on the page —
382
+ // "nothing in it is being checked" — about the two products directly above it.
383
+ const alreadyFound = (/** @type {string} */ p) => p.startsWith(`${folder}/`);
384
+ if (merged.some((p) => alreadyFound(p.where)) || members.some((m) => alreadyFound(m.root))) continue;
357
385
  // The root product's own source is not an unclaimed folder. These are the folders the
358
386
  // contract channel already read, plus the ones that are never a product on their own.
359
387
  if (ALREADY_COVERED.has(folder)) continue;
@@ -387,7 +415,7 @@ export async function detectProject(options = {}) {
387
415
  languages: await languagesIn(root),
388
416
  tests,
389
417
  scripts: scriptsOf(pkg?.scripts ?? {}),
390
- ...(await theSourceAgain({ root, readCode, merged, listing, first: { doors, routes, channels, envNames } })),
418
+ ...(await theSourceAgain({ root, readCode, merged, listing, firstFolders, first: { doors, routes, channels, envNames } })),
391
419
  bulk: await measureBulk(root),
392
420
  pages,
393
421
  containers,
@@ -953,6 +981,94 @@ async function findMembers(root, globs, listing) {
953
981
  return members;
954
982
  }
955
983
 
984
+ /**
985
+ * The doors that are the ROOT'S, once the ones belonging to sub-packages are handed back.
986
+ *
987
+ * The source is read once, from the top, which is what keeps this fast — but the routes it
988
+ * comes back with are the whole repository's, and the root is then judged on them. In a
989
+ * workspaces monorepo that made the root itself "the server", off the strength of routes
990
+ * written in `packages/api`: a shelf holding two packages, reported as a third product that
991
+ * ships nothing. `packages/api` was already in the list, correctly, one line above it.
992
+ *
993
+ * Only a clean sweep counts. The moment ONE route was read outside every member, the root has
994
+ * routes of its own and keeps the full count — losing a real server is far worse than listing
995
+ * a doubtful one, so the doubt goes that way. The route list is capped at 200 names, so this
996
+ * is a sample rather than a census on a repository with more than that; a root with routes of
997
+ * its own would have to contribute none of the first 200 to be missed, and its framework
998
+ * dependency or its own `server.js` says it is a server anyway.
999
+ *
1000
+ * @param {ProjectShape['doors']} doors
1001
+ * @param {ProjectShape['routes']} routes
1002
+ * @param {{name: string, root: string}[]} members
1003
+ * @returns {ProjectShape['doors']}
1004
+ */
1005
+ function theRootsOwnDoors(doors, routes, members) {
1006
+ if (members.length === 0 || routes.length === 0 || doors.route === 0) return doors;
1007
+ const slashed = (/** @type {string} */ p) => p.split(path.sep).join('/');
1008
+ const theirs = members.map((m) => slashed(m.root));
1009
+ const inAMember = (/** @type {string} */ file) =>
1010
+ theirs.some((their) => slashed(file).startsWith(`${their}/`));
1011
+ if (!routes.every((one) => inAMember(one.file))) return doors;
1012
+ return { ...doors, route: 0 };
1013
+ }
1014
+
1015
+ /**
1016
+ * Does the project keep code of its own at the top level, outside every folder below it?
1017
+ *
1018
+ * A repository whose whole server is one `server.js` beside `package.json` is completely
1019
+ * normal, and it is the shape this file used to go blind on the moment somebody added a
1020
+ * `src/` folder for something else.
1021
+ *
1022
+ * Only files that are the PRODUCT count. A build config, a declaration file and a test all
1023
+ * sit at the top level of nearly every repository, and treating any of them as "the project
1024
+ * keeps code up here" would make this true everywhere and so worth nothing.
1025
+ *
1026
+ * @param {{files: string[], dirs: string[]}} listing
1027
+ * @returns {boolean}
1028
+ */
1029
+ function rootHoldsItsOwnCode(listing) {
1030
+ return listing.files.some((name) => {
1031
+ if (!/\.[cm]?[jt]sx?$/.test(name)) return false;
1032
+ if (name.startsWith('.')) return false;
1033
+ if (/\.d\.[cm]?ts$/.test(name)) return false; // a declaration describes, it opens nothing
1034
+ if (/\.(test|spec)\.[cm]?[jt]sx?$/.test(name)) return false;
1035
+ if (/\.(config|conf)\.[cm]?[jt]sx?$/.test(name)) return false;
1036
+ return !ROOT_TOOLING_FILES.has(name.toLowerCase());
1037
+ });
1038
+ }
1039
+
1040
+ /**
1041
+ * Which folders to hand the source reader.
1042
+ *
1043
+ * THE FAILURE THIS EXISTS TO STOP, and it is the worst kind this tool has. The reader is
1044
+ * pointed at a list of folders, and it reads THOSE and nothing else. So a project with its
1045
+ * server in `server.js` at the top level and a `src/` folder holding anything at all had its
1046
+ * server never opened: four routes read as zero, and every later question about them answered
1047
+ * "nothing that worked has changed". A deleted route and a route that started returning 500
1048
+ * both came back clean, exit 0. Measured on a four-route express server: 4 routes with no
1049
+ * `src/` folder, 0 routes the moment an unrelated `src/` folder existed beside it.
1050
+ *
1051
+ * The reader can only be aimed at folders, never at single files, and the top-level files sit
1052
+ * outside every folder there is. So when the project keeps code up there, the answer is the
1053
+ * whole project — which is exactly what the reader already does for a project that has no
1054
+ * `src/` at all. The two cases now behave the same way instead of one of them going silent.
1055
+ *
1056
+ * The cost of that is a wider read: an `examples/` folder gets opened too, and a route written
1057
+ * in an example is counted. That is the right direction to be wrong in. An extra route makes a
1058
+ * check ask for an address that answers 404 both times, which changes nothing and alarms
1059
+ * nobody; a MISSING route makes the tool say "nothing that worked has changed" about a product
1060
+ * whose orders endpoint has gone. Aiming the reader at single files would fix both, and that
1061
+ * lives in `collectFiles` in the source adapter rather than here.
1062
+ *
1063
+ * @param {{files: string[], dirs: string[]}} listing
1064
+ * @returns {string[]} Folders to read. Empty means the reader falls back to the whole
1065
+ * project, which is its own long-standing behaviour.
1066
+ */
1067
+ function whereTheCodeIs(listing) {
1068
+ if (rootHoldsItsOwnCode(listing)) return ['.'];
1069
+ return USUAL_SOURCE_FOLDERS.filter((name) => listing.dirs.includes(name));
1070
+ }
1071
+
956
1072
  /**
957
1073
  * Every door in the source, counted AND named, using the same reader the contract channel
958
1074
  * uses so the number here and the number in a check can never disagree.
@@ -1951,6 +2067,40 @@ async function foreignServerIn(dir, sources, language) {
1951
2067
  return { yes: false, file: null, framework: null, readsPort: false };
1952
2068
  }
1953
2069
 
2070
+ /**
2071
+ * Does this file belong to a package sitting INSIDE the folder being looked at?
2072
+ *
2073
+ * THE FAILURE THIS EXISTS TO STOP. Both server readings below open files three folders deep,
2074
+ * which is right for a product folder and wrong for a shelf. In a workspaces monorepo it made
2075
+ * `packages/api/src/server.js` count as evidence about `packages/` itself, and a folder that
2076
+ * ships nothing at all was announced as "the server in packages/" with 0.8 confidence — a
2077
+ * product that does not exist, sitting in the list beside four that do. The same file had
2078
+ * already been read correctly one folder down, where it actually lives.
2079
+ *
2080
+ * A `package.json` on the way down is the line. Everything below it is that package's, and
2081
+ * that package is looked at in its own right.
2082
+ *
2083
+ * @param {string} dir The folder being asked about.
2084
+ * @param {string} rel A file inside it, relative to it.
2085
+ * @param {Map<string, boolean>} seen Answers already worked out, so one walk costs one look.
2086
+ * @returns {boolean}
2087
+ */
2088
+ function insideAnotherPackage(dir, rel, seen) {
2089
+ const parts = rel.split(path.sep);
2090
+ parts.pop(); // the filename itself is never a folder
2091
+ let sofar = '';
2092
+ for (const part of parts) {
2093
+ sofar = sofar ? path.join(sofar, part) : part;
2094
+ let itsOwn = seen.get(sofar);
2095
+ if (itsOwn === undefined) {
2096
+ itsOwn = fs.existsSync(path.join(dir, sofar, 'package.json'));
2097
+ seen.set(sofar, itsOwn);
2098
+ }
2099
+ if (itsOwn) return true;
2100
+ }
2101
+ return false;
2102
+ }
2103
+
1954
2104
  /**
1955
2105
  * Does a folder hold a server somebody wrote by hand, on node's own http module?
1956
2106
  *
@@ -1969,8 +2119,13 @@ async function foreignServerIn(dir, sources, language) {
1969
2119
  */
1970
2120
  async function handWrittenServerIn(dir) {
1971
2121
  const { files } = await readSome(dir, { most: 80, depth: 3 });
2122
+ /** @type {Map<string, boolean>} */
2123
+ const packagesInside = new Map();
1972
2124
  for (const one of files) {
1973
2125
  const where = one.rel.split(path.sep).join('/');
2126
+ // Somebody else's package, read on the way past. It is a product in its own right and is
2127
+ // found as one; borrowing its server for the folder above invents a second product.
2128
+ if (insideAnotherPackage(dir, one.rel, packagesInside)) continue;
1974
2129
  // A server standing in a fixtures folder is a prop for somebody's test, not the product.
1975
2130
  // This tool's own repository has one, and without this line it reported ITSELF as a
1976
2131
  // server — which is exactly the kind of confident wrong answer that gets a tool switched
@@ -2007,8 +2162,13 @@ async function handWrittenServerIn(dir) {
2007
2162
  */
2008
2163
  async function looksLikeAServer(dir) {
2009
2164
  const { files } = await readSome(dir, { most: 60, depth: 3 });
2165
+ /** @type {Map<string, boolean>} */
2166
+ const packagesInside = new Map();
2010
2167
  for (const one of files) {
2011
2168
  if (/\.(test|spec)\.[cm]?[jt]sx?$/.test(one.rel)) continue;
2169
+ // The same line as above: a socket opened inside a package of its own says nothing about
2170
+ // the folder that package happens to sit in. `apps/` is not a server because `apps/api` is.
2171
+ if (insideAnotherPackage(dir, one.rel, packagesInside)) continue;
2012
2172
  const listens = /\.listen\s*\(|createServer\s*\(|Deno\.serve\s*\(|Bun\.serve\s*\(|serve\s*\(\s*\{[^}]*port/.test(one.text);
2013
2173
  if (!listens) continue;
2014
2174
  const readsPort = /process\.env\.PORT|Deno\.env\.get\(\s*['"]PORT|env\.PORT/.test(one.text);
@@ -2291,7 +2451,13 @@ function startCommandFor(input) {
2291
2451
  // build. It takes the port as a flag, so nothing has to be downloaded to serve the files.
2292
2452
  if (build && script('preview') && (has('vite') || has('astro') || has('@sveltejs/kit'))) {
2293
2453
  return {
2294
- command: `${build} && ${script('preview')} -- --port $PORT --strictPort`,
2454
+ // `--host 127.0.0.1` is not decoration. Measured on 2026-08-31 on an app scaffolded a
2455
+ // minute earlier with `npm create vite@latest -- --template react-ts`: Vite ignores both
2456
+ // the PORT and the HOST it is handed in the environment and binds the NAME `localhost`,
2457
+ // which macOS resolves to the IPv6 loopback — so the site came up on `[::1]` and nothing
2458
+ // whatever was listening on `127.0.0.1`. Naming the address makes the command land where
2459
+ // the settings say it will, instead of wherever name resolution happens to put it.
2460
+ command: `${build} && ${script('preview')} -- --port $PORT --strictPort --host 127.0.0.1`,
2295
2461
  kind: 'build-and-serve',
2296
2462
  why: 'It is built, and then the build is served by the tool that made it. That is what ships — a dev server serves unbundled source with a live-reload connection in every page, which is a second thing moving under the comparison.',
2297
2463
  };
@@ -2505,10 +2671,15 @@ function inGigabytes(bytes) {
2505
2671
  * @returns {Promise<string[]>}
2506
2672
  */
2507
2673
  async function proposeSourceFolders(root, products, listing) {
2674
+ // Code at the top level belongs to no folder, and the reader only takes folders. So the
2675
+ // settings this writes have to say "the whole project" rather than name a folder that would
2676
+ // leave the project's own `server.js` unopened for good — the same silence
2677
+ // {@link whereTheCodeIs} exists to stop, except written down and kept.
2678
+ if (rootHoldsItsOwnCode(listing)) return ['.'];
2679
+
2508
2680
  /** @type {Set<string>} */
2509
2681
  const folders = new Set();
2510
- const usual = ['src', 'lib', 'app', 'bin', 'server', 'pages', 'api', 'electron', 'main', 'packages'];
2511
- for (const name of usual) if (listing.dirs.includes(name)) folders.add(name);
2682
+ for (const name of USUAL_SOURCE_FOLDERS) if (listing.dirs.includes(name)) folders.add(name);
2512
2683
 
2513
2684
  for (const product of products) {
2514
2685
  if (product.where === '.' || product.where === '') continue;
@@ -2772,14 +2943,16 @@ function plainly(items) {
2772
2943
  * @param {boolean} input.readCode
2773
2944
  * @param {Product[]} input.merged
2774
2945
  * @param {{files: string[], dirs: string[]}} input.listing
2946
+ * @param {string[]} input.firstFolders The folders the first read was actually given. Worked
2947
+ * out again from the usual list, this said "src was read" about a run that had in fact been
2948
+ * pointed at the whole project, and the whole project then got read a second time for nothing.
2775
2949
  * @param {{doors: ProjectShape['doors'], routes: ProjectShape['routes'], channels: ProjectShape['channels'], envNames: ProjectShape['envNames']}} input.first
2776
2950
  * @returns {Promise<{doors: ProjectShape['doors'], routes: ProjectShape['routes'], channels: ProjectShape['channels'], envNames: ProjectShape['envNames'], sourceFolders: string[]}>}
2777
2951
  */
2778
2952
  async function theSourceAgain(input) {
2779
- const { root, readCode, merged, listing, first } = input;
2953
+ const { root, readCode, merged, listing, firstFolders, first } = input;
2780
2954
  const sourceFolders = await proposeSourceFolders(root, merged, listing);
2781
- const usual = ['src', 'lib', 'app', 'bin', 'server', 'pages', 'api', 'electron', 'main', 'packages'];
2782
- const alreadyRead = new Set(usual.filter((name) => listing.dirs.includes(name)));
2955
+ const alreadyRead = new Set(firstFolders);
2783
2956
  const missed = sourceFolders.filter((folder) => !alreadyRead.has(folder));
2784
2957
  if (!readCode || missed.length === 0) return { ...first, sourceFolders };
2785
2958