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.
- package/CHANGELOG.md +182 -0
- package/README.md +17 -5
- package/docs/getting-started.md +10 -0
- package/docs/how-v2-works.md +5 -2
- package/package.json +2 -2
- package/src/guard/api.js +107 -3
- package/src/guard/run.js +154 -20
- package/src/report/console.js +235 -17
- package/src/report/html.js +75 -19
- package/src/types.js +5 -0
- package/src/v2/adapters/android-driver.js +62 -12
- package/src/v2/adapters/contract.js +18 -4
- package/src/v2/adapters/electron.js +96 -14
- package/src/v2/adapters/http.js +264 -23
- package/src/v2/adapters/ios-driver.js +22 -4
- package/src/v2/adapters/ios.js +5 -2
- package/src/v2/adapters/isolate.js +78 -5
- package/src/v2/adapters/process.js +350 -92
- package/src/v2/adapters/web-driver.js +23 -1
- package/src/v2/adapters/web.js +42 -3
- package/src/v2/adapters/windows.js +32 -15
- package/src/v2/check.js +526 -19
- package/src/v2/cli.js +345 -3
- package/src/v2/cluster.js +112 -4
- package/src/v2/coverage.js +293 -8
- package/src/v2/detect.js +182 -9
- package/src/v2/doctor.js +253 -30
- package/src/v2/init.js +102 -10
- package/src/v2/mcp/server.js +4 -1
- package/src/v2/mcp/tools.js +291 -24
- package/src/v2/normalise.js +11 -0
- package/src/v2/observation.js +57 -5
- package/src/v2/reference.js +133 -14
- package/src/v2/refusal.js +389 -0
- package/src/v2/remote.js +24 -3
- package/src/v2/run.js +306 -16
- package/src/v2/sealed.js +14 -2
- package/src/v2/ship.js +286 -22
- package/src/v2/store.js +101 -2
- package/src/v2/types.js +5 -0
- package/src/v2/waiver.js +9 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|