staysfixed 0.10.0 → 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 +125 -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 +319 -9
- package/src/v2/cli.js +345 -3
- package/src/v2/cluster.js +112 -4
- package/src/v2/coverage.js +208 -8
- package/src/v2/detect.js +182 -9
- package/src/v2/doctor.js +168 -30
- package/src/v2/init.js +88 -10
- package/src/v2/mcp/server.js +4 -1
- package/src/v2/mcp/tools.js +291 -24
- 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
|
@@ -79,6 +79,9 @@ import {
|
|
|
79
79
|
trimForStorage,
|
|
80
80
|
} from './contract.js';
|
|
81
81
|
import { RemoteLinkLost, remoteRunner } from '../remote.js';
|
|
82
|
+
// Every wait here has a limit and every limit says what it was waiting for; the pieces are in
|
|
83
|
+
// process.js so there is one of each rather than one per adapter.
|
|
84
|
+
import { endOfChild, letGoOf } from './process.js';
|
|
82
85
|
|
|
83
86
|
/** @typedef {import('./contract.js').Build} Build */
|
|
84
87
|
/** @typedef {import('./contract.js').PreparedBuild} PreparedBuild */
|
|
@@ -814,21 +817,35 @@ export function asList(value) {
|
|
|
814
817
|
*/
|
|
815
818
|
export async function pushBuild(host, localDir, remoteDir) {
|
|
816
819
|
const started = Date.now();
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
820
|
+
// `ConnectTimeout` because ssh with no answer at the far end will sit on a half-open socket
|
|
821
|
+
// for as long as the network lets it, and the whole point of this pass is that nothing here
|
|
822
|
+
// is allowed to wait without a clock on it.
|
|
823
|
+
const tar = spawn('tar', ['-cf', '-', '-C', path.dirname(localDir), path.basename(localDir)]);
|
|
824
|
+
const ssh = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=15', host, `mkdir -p '${remoteDir}' && tar -xf - -C '${remoteDir}'`]);
|
|
825
|
+
let trouble = '';
|
|
826
|
+
tar.stdout.pipe(ssh.stdin);
|
|
827
|
+
ssh.stderr.on('data', (d) => { trouble += String(d); });
|
|
828
|
+
tar.stderr.on('data', (d) => { trouble += String(d); });
|
|
829
|
+
// Nothing wants what ssh prints on its way through, and that is exactly why it has to be
|
|
830
|
+
// read. A pipe nobody empties fills up, and a full pipe blocks its writer for ever — the
|
|
831
|
+
// same hang as an unclosed one, arriving from the other direction.
|
|
832
|
+
ssh.stdout?.resume();
|
|
833
|
+
// A pipe with nobody reading it fills up and blocks the writer for ever, so if ssh is gone
|
|
834
|
+
// tar has to be told rather than left leaning on a dead pipe.
|
|
835
|
+
ssh.on('error', () => { try { tar.kill('SIGKILL'); } catch { /* already gone */ } });
|
|
836
|
+
tar.on('error', () => { try { ssh.kill('SIGKILL'); } catch { /* already gone */ } });
|
|
837
|
+
|
|
838
|
+
// Thirty minutes for a whole build over a network, which is far longer than it has ever
|
|
839
|
+
// taken and still a limit. A copy that never finishes and never says so is the shape of the
|
|
840
|
+
// hang this whole pass exists to remove.
|
|
841
|
+
const ended = await endOfChild(ssh, { limitMs: 30 * 60_000, what: `the copy of this build to ${host}` });
|
|
842
|
+
try { tar.kill('SIGKILL'); } catch { /* it finished on its own */ }
|
|
843
|
+
letGoOf(tar);
|
|
844
|
+
|
|
845
|
+
const ms = Date.now() - started;
|
|
846
|
+
if (ended.gaveUp) return { ok: false, ms, why: `${ended.why} ${trouble.trim().slice(0, 200)}`.trim() };
|
|
847
|
+
if (ended.code === 0) return { ok: true, ms, why: `Copied to ${host} in ${timeBucket(ms)}.` };
|
|
848
|
+
return { ok: false, ms, why: `Copying to ${host} failed: ${trouble.trim().slice(0, 300) || `the copy ended with ${ended.code ?? ended.signal}`}` };
|
|
832
849
|
}
|
|
833
850
|
|
|
834
851
|
// ---------------------------------------------------------------------------
|
package/src/v2/check.js
CHANGED
|
@@ -32,7 +32,7 @@ import { createHash } from 'node:crypto';
|
|
|
32
32
|
import { promisify } from 'node:util';
|
|
33
33
|
|
|
34
34
|
import { StaysFixedError, messageOf } from '../core/errors.js';
|
|
35
|
-
import { warn, detail } from '../core/log.js';
|
|
35
|
+
import { warn, detail, shortPath } from '../core/log.js';
|
|
36
36
|
import { findConfigFile, rootForConfig } from '../core/paths.js';
|
|
37
37
|
import { sha256 } from '../core/hash.js';
|
|
38
38
|
|
|
@@ -362,6 +362,10 @@ export async function check(options = {}) {
|
|
|
362
362
|
|
|
363
363
|
/** @type {CheckOutcome} */
|
|
364
364
|
const outcome = await settle(verdict, project.store, project.product, named);
|
|
365
|
+
// FIRST, before anything the run found. Which product was walked is the frame every
|
|
366
|
+
// other sentence here has to be read inside, and a person who was told it at the end
|
|
367
|
+
// has already read the clean result as being about the folder they are standing in.
|
|
368
|
+
if (project.elsewhere !== '') outcome.summary = `${project.elsewhere} ${outcome.summary}`;
|
|
365
369
|
// Only a run that really did reach the surface it was aimed at may say so. The
|
|
366
370
|
// confirmation is what lets a caller tell "it went there and found nothing" from
|
|
367
371
|
// "it checked something else and found nothing", and those are not the same answer.
|
|
@@ -465,7 +469,14 @@ async function settle(verdict, store, product, guards) {
|
|
|
465
469
|
verdict.findings = decided.reported;
|
|
466
470
|
verdict.accounted = decided.accounting;
|
|
467
471
|
if (verdict.blocked !== true) {
|
|
468
|
-
verdict.ok
|
|
472
|
+
// `verdict.ok !== false` first, and it is the whole point of the line. Accounting may
|
|
473
|
+
// take a pass AWAY — a finding nobody waived, an address that stopped being
|
|
474
|
+
// predictable — and it may never hand one back. This assigned instead of narrowing, so
|
|
475
|
+
// every not-a-pass the engine had already decided was thrown away here: a run where the
|
|
476
|
+
// product never answered, and a run drowning in wobble, both came back through this line
|
|
477
|
+
// as `ok: true`. Found by the refusal lane on 2026-08-31, and it had been quietly
|
|
478
|
+
// discarding the wobble verdict before that.
|
|
479
|
+
verdict.ok = verdict.ok !== false && decided.reported.length === 0 && (verdict.newlyUnstable ?? []).length === 0;
|
|
469
480
|
// The count goes into the sentence a person and an agent both read, not into a field
|
|
470
481
|
// one of them has to know to look for.
|
|
471
482
|
if (decided.accounting.waived > 0 || decided.accounting.expiredWaivers > 0) {
|
|
@@ -657,7 +668,11 @@ export function whatWasNotChecked(coverage) {
|
|
|
657
668
|
if (parts.length === 0) {
|
|
658
669
|
return `Everything this run knows how to walk was walked — ${coverage.paths} ${coverage.paths === 1 ? 'address' : 'addresses'} across ${coverage.journeys} ${coverage.journeys === 1 ? 'journey' : 'journeys'}. That is not every possible state of your product; nothing can enumerate that, and a clean result only covers what was walked.`;
|
|
659
670
|
}
|
|
660
|
-
|
|
671
|
+
// `staysfixed coverage`, not `coverage.gaps`. This sentence is printed at a person as well
|
|
672
|
+
// as returned to an agent, and a person at a terminal has no JSON field to open — being
|
|
673
|
+
// told to read one is being told to go nowhere. The command is true for both readers, and
|
|
674
|
+
// the agent already had that wording on its own reply.
|
|
675
|
+
return `NOT EVERYTHING WAS CHECKED: ${parts.join(', and ')}. A clean result only covers what was walked — \`staysfixed coverage\` has the whole list.`;
|
|
661
676
|
}
|
|
662
677
|
|
|
663
678
|
/**
|
|
@@ -902,6 +917,13 @@ export async function sweepAbandonedScratch() {
|
|
|
902
917
|
} catch {
|
|
903
918
|
return;
|
|
904
919
|
}
|
|
920
|
+
// Programs whose folder has ALREADY gone are swept first, because the loop below can never
|
|
921
|
+
// reach them: it walks folders, and theirs is not there any more. Fifteen `serve` processes
|
|
922
|
+
// were found in exactly that state on 2026-08-31, out of scratch folders deleted the day
|
|
923
|
+
// before. A folder that no longer exists is the strongest possible evidence that its run is
|
|
924
|
+
// over, so nothing that is still going is at risk here.
|
|
925
|
+
await stopRunsWhoseFolderHasGone();
|
|
926
|
+
|
|
905
927
|
let taken = 0;
|
|
906
928
|
for (const name of names) {
|
|
907
929
|
if (taken >= MOST_PER_RUN) break;
|
|
@@ -921,11 +943,168 @@ export async function sweepAbandonedScratch() {
|
|
|
921
943
|
}
|
|
922
944
|
}
|
|
923
945
|
if (!abandoned) continue;
|
|
946
|
+
// The folder is not the whole of what was left behind. Four `vite preview` servers from
|
|
947
|
+
// the day before were still running on this machine on 2026-08-31, out of scratch folders
|
|
948
|
+
// that had already been deleted — started by a run that died before it could stop them.
|
|
949
|
+
// Deleting the folder and leaving its programs running is a tool quietly consuming
|
|
950
|
+
// somebody's machine, and this one is going to be installed on machines that are not its
|
|
951
|
+
// author's. So the programs go first, and only then the folder.
|
|
952
|
+
await stopWhateverIsStillRunningIn(dir);
|
|
924
953
|
await fsp.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
925
954
|
taken += 1;
|
|
926
955
|
}
|
|
927
956
|
}
|
|
928
957
|
|
|
958
|
+
/**
|
|
959
|
+
* Stop programs still running out of a scratch folder that has already been deleted.
|
|
960
|
+
*
|
|
961
|
+
* Every one of these was started by a check and outlived it. They hold ports and memory on a
|
|
962
|
+
* machine that is usually not this tool's author's, and nothing else on it has a
|
|
963
|
+
* `staysfixed-check-` path in its command line, so the match cannot catch a stranger.
|
|
964
|
+
*
|
|
965
|
+
* A folder that still exists is left completely alone here — a run that is going right now
|
|
966
|
+
* has its folder, and stopping its own servers would be this function breaking the check that
|
|
967
|
+
* called it.
|
|
968
|
+
*
|
|
969
|
+
* POSIX only, for the same reason as {@link stopWhateverIsStillRunningIn}: asking Windows
|
|
970
|
+
* this question needs a different command, and a wrong one there could stop something else.
|
|
971
|
+
*
|
|
972
|
+
* @returns {Promise<void>}
|
|
973
|
+
*/
|
|
974
|
+
async function stopRunsWhoseFolderHasGone() {
|
|
975
|
+
if (process.platform === 'win32') return;
|
|
976
|
+
/** @type {string} */
|
|
977
|
+
let listing = '';
|
|
978
|
+
try {
|
|
979
|
+
listing = (await exec('/bin/ps', ['-A', '-o', 'pid=,command='], { timeout: 10_000, maxBuffer: 8 * 1024 * 1024 })).stdout;
|
|
980
|
+
} catch {
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
for (const line of listing.split('\n')) {
|
|
984
|
+
const folder = /(\S*staysfixed-check-[A-Za-z0-9]+)/.exec(line);
|
|
985
|
+
if (!folder) continue;
|
|
986
|
+
if (existsSync(folder[1])) continue;
|
|
987
|
+
const pid = Number.parseInt(line.trim().split(/\s+/)[0] ?? '', 10);
|
|
988
|
+
if (!Number.isFinite(pid) || pid <= 1 || pid === process.pid) continue;
|
|
989
|
+
for (const signal of /** @type {const} */ (['SIGTERM', 'SIGKILL'])) {
|
|
990
|
+
try {
|
|
991
|
+
process.kill(-pid, signal);
|
|
992
|
+
} catch {
|
|
993
|
+
try {
|
|
994
|
+
process.kill(pid, signal);
|
|
995
|
+
} catch {
|
|
996
|
+
// Gone already, or not ours to stop.
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/**
|
|
1004
|
+
* Stop anything still running out of an abandoned scratch folder.
|
|
1005
|
+
*
|
|
1006
|
+
* Read from the process list rather than remembered, because the run that started these is
|
|
1007
|
+
* gone — that is what made the folder abandoned. A program is only killed when the folder
|
|
1008
|
+
* being reclaimed appears in its own command line, so nothing of anybody else's is touched.
|
|
1009
|
+
*
|
|
1010
|
+
* The whole group is signalled, not the one process: a server started through `npm run` is
|
|
1011
|
+
* a shell that started Node, and killing the shell alone leaves the server holding its port.
|
|
1012
|
+
*
|
|
1013
|
+
* POSIX only. Windows needs a different question asked of the machine, and a wrong one there
|
|
1014
|
+
* could kill something else, so it is left alone and said so rather than guessed at.
|
|
1015
|
+
*
|
|
1016
|
+
* @param {string} dir
|
|
1017
|
+
* @returns {Promise<void>}
|
|
1018
|
+
*/
|
|
1019
|
+
async function stopWhateverIsStillRunningIn(dir) {
|
|
1020
|
+
if (process.platform === 'win32') return;
|
|
1021
|
+
/** @type {string} */
|
|
1022
|
+
let listing = '';
|
|
1023
|
+
try {
|
|
1024
|
+
listing = (await exec('/bin/ps', ['-A', '-o', 'pid=,command='], { timeout: 10_000, maxBuffer: 8 * 1024 * 1024 })).stdout;
|
|
1025
|
+
} catch {
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
for (const line of listing.split('\n')) {
|
|
1029
|
+
if (!line.includes(dir)) continue;
|
|
1030
|
+
const pid = Number.parseInt(line.trim().split(/\s+/)[0] ?? '', 10);
|
|
1031
|
+
if (!Number.isFinite(pid) || pid <= 1 || pid === process.pid) continue;
|
|
1032
|
+
for (const signal of /** @type {const} */ (['SIGTERM', 'SIGKILL'])) {
|
|
1033
|
+
try {
|
|
1034
|
+
process.kill(-pid, signal);
|
|
1035
|
+
} catch {
|
|
1036
|
+
try {
|
|
1037
|
+
process.kill(pid, signal);
|
|
1038
|
+
} catch {
|
|
1039
|
+
// Already gone, or somebody else's to stop. Either way there is nothing to do.
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* "There was nowhere to work", said the way every other refusal here is said.
|
|
1048
|
+
*
|
|
1049
|
+
* A check never runs anything against somebody's real folder: it copies the build into a
|
|
1050
|
+
* throwaway folder inside the machine's temporary directory first. When that folder cannot
|
|
1051
|
+
* be made, the run is over before it starts — and what a person was handed for it was the
|
|
1052
|
+
* operating system's own words, `ENOENT: no such file or directory, mkdtemp
|
|
1053
|
+
* '/nowhere/staysfixed-check-FHwIxx'`, pasted straight into the block this tool tells an
|
|
1054
|
+
* agent to put in a summary for the person who owns the product. Measured 2026-08-31 with
|
|
1055
|
+
* TMPDIR pointing at a folder that was not there, and again at one this user could not
|
|
1056
|
+
* write to. Every other refusal in this file is a plain sentence; that one was a stack.
|
|
1057
|
+
*
|
|
1058
|
+
* The three that actually happen each get their own sentence, because the thing to DO about
|
|
1059
|
+
* them is different every time: the folder is not there, the folder will not take writes, or
|
|
1060
|
+
* the disk is full. Anything else keeps the machine's own words, framed as the machine's —
|
|
1061
|
+
* when there is no sentence for it, the raw text is the only information there is, and
|
|
1062
|
+
* dropping it would leave somebody with nothing at all.
|
|
1063
|
+
*
|
|
1064
|
+
* @param {unknown} e
|
|
1065
|
+
* @returns {StaysFixedError}
|
|
1066
|
+
*/
|
|
1067
|
+
function noScratchFolder(e) {
|
|
1068
|
+
const tmp = os.tmpdir();
|
|
1069
|
+
const code = String(/** @type {any} */ (e)?.code ?? '');
|
|
1070
|
+
// Worth naming only when a setting in this shell is what chose the folder. On a machine
|
|
1071
|
+
// where nothing set it, saying "TMPDIR" sends somebody looking for a setting they have not
|
|
1072
|
+
// got, and the folder is the operating system's own.
|
|
1073
|
+
const yours = (process.env.TMPDIR ?? '').replace(/\/$/, '') === tmp.replace(/\/$/, '')
|
|
1074
|
+
? ' That folder is whatever TMPDIR is set to in this shell.'
|
|
1075
|
+
: '';
|
|
1076
|
+
/** @type {{why: string, hint: string}} */
|
|
1077
|
+
const said =
|
|
1078
|
+
code === 'ENOENT'
|
|
1079
|
+
? {
|
|
1080
|
+
why: `There is no folder at ${tmp}, so there was nowhere to put it.`,
|
|
1081
|
+
hint: `Make that folder, or point TMPDIR at one that exists — or unset TMPDIR to fall back to this machine's own — and run the check again.${yours}`,
|
|
1082
|
+
}
|
|
1083
|
+
: code === 'EACCES' || code === 'EPERM'
|
|
1084
|
+
? {
|
|
1085
|
+
why: `${tmp} is there, but this user is not allowed to write in it.`,
|
|
1086
|
+
hint: `Give yourself write access to that folder, or point TMPDIR at one you can write to, and run the check again.${yours}`,
|
|
1087
|
+
}
|
|
1088
|
+
: code === 'EROFS'
|
|
1089
|
+
? {
|
|
1090
|
+
why: `${tmp} is on a disk that is mounted read-only, so nothing can be written there at all.`,
|
|
1091
|
+
hint: `Point TMPDIR at a folder on a disk that takes writes and run the check again.${yours}`,
|
|
1092
|
+
}
|
|
1093
|
+
: code === 'ENOSPC'
|
|
1094
|
+
? {
|
|
1095
|
+
why: `The disk holding ${tmp} is full.`,
|
|
1096
|
+
hint: 'Free some space and run the check again. A check copies your project, so it needs about as much room as the project takes.',
|
|
1097
|
+
}
|
|
1098
|
+
: {
|
|
1099
|
+
why: `${tmp} would not take it. The machine said: ${messageOf(e)}`,
|
|
1100
|
+
hint: `Check that ${tmp} exists and that you can write in it, then run the check again.${yours}`,
|
|
1101
|
+
};
|
|
1102
|
+
return new StaysFixedError(
|
|
1103
|
+
`Stays Fixed could not make the throwaway folder it works in, so nothing was opened, nothing was walked and nothing was compared. ${said.why}`,
|
|
1104
|
+
{ hint: said.hint, cause: e },
|
|
1105
|
+
);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
929
1108
|
/**
|
|
930
1109
|
* Is that process still running? Signal 0 asks without sending anything.
|
|
931
1110
|
* @param {number} pid
|
|
@@ -961,8 +1140,20 @@ function plainly(what) {
|
|
|
961
1140
|
* @returns {CheckOutcome}
|
|
962
1141
|
*/
|
|
963
1142
|
function blocked(options, e, storeTrouble) {
|
|
964
|
-
|
|
1143
|
+
// The basename of the ROOT, not of the folder the command was typed in. Those are the
|
|
1144
|
+
// same folder on almost every run, and on the one where they differ this record is
|
|
1145
|
+
// written into the root's store — so naming it after the folder somebody happened to be
|
|
1146
|
+
// standing in filed a run under a product that store has never heard of.
|
|
1147
|
+
const root = projectRootFor(options);
|
|
1148
|
+
const product = options.product ?? path.basename(root);
|
|
965
1149
|
const empty = { id: '', product };
|
|
1150
|
+
// Which folder this is about, said before the reason it could not be done. A blocked run
|
|
1151
|
+
// inside a sub-project is the easiest of all to misread: nothing was walked, so there is
|
|
1152
|
+
// nothing in the answer to give away that it was never about this folder in the first
|
|
1153
|
+
// place. `options.product` is passed rather than the name worked out above, because a name
|
|
1154
|
+
// taken from a folder is not a product name and quoting it as one would put a product in
|
|
1155
|
+
// front of somebody that nothing anywhere calls that.
|
|
1156
|
+
const elsewhere = aboutSomewhereElse({ from: startedIn(options), root, product: options.product })?.note ?? '';
|
|
966
1157
|
return {
|
|
967
1158
|
runId: new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14),
|
|
968
1159
|
product,
|
|
@@ -985,7 +1176,7 @@ function blocked(options, e, storeTrouble) {
|
|
|
985
1176
|
// The hint is the half that tells a person what to DO about it, and dropping it
|
|
986
1177
|
// turns a helpful error into a dead end. Anything that blocks a run has to carry
|
|
987
1178
|
// both halves all the way out to whoever reads the summary.
|
|
988
|
-
summary: `The check could not be run, so this is not a pass and not a failure. ${storeTrouble ? `${storeTrouble} ` : ''}${messageOf(e)}${
|
|
1179
|
+
summary: `${elsewhere ? `${elsewhere} ` : ''}The check could not be run, so this is not a pass and not a failure. ${storeTrouble ? `${storeTrouble} ` : ''}${messageOf(e)}${
|
|
989
1180
|
e instanceof Error && /** @type {any} */ (e).hint ? ` ${/** @type {any} */ (e).hint}` : ''
|
|
990
1181
|
}`,
|
|
991
1182
|
durationMs: 0,
|
|
@@ -1393,6 +1584,10 @@ async function waitForItsWindow(pid, stopped) {
|
|
|
1393
1584
|
* @property {string} storeTrouble Empty on a normal run. A plain sentence when the store
|
|
1394
1585
|
* would not take this run's records — the run went ahead anyway, and every answer it
|
|
1395
1586
|
* produces has to carry the admission that nothing about it was kept.
|
|
1587
|
+
* @property {string} elsewhere Empty when the check is about the folder it was typed in.
|
|
1588
|
+
* Otherwise the sentence naming which product this run is really about and where it is,
|
|
1589
|
+
* which goes on the FRONT of the answer so nobody reads a clean result as being about a
|
|
1590
|
+
* folder that was never walked.
|
|
1396
1591
|
* @property {import('./run.js').Walker} walk
|
|
1397
1592
|
* @property {(reference: BuildFingerprint, ctx: {events?: CheckEvents, signal?: AbortSignal}) => Promise<LiveBuild|null>} bootReference
|
|
1398
1593
|
* @property {(capture: Capture) => Capture} normalise
|
|
@@ -1409,11 +1604,98 @@ async function waitForItsWindow(pid, stopped) {
|
|
|
1409
1604
|
* @returns {string}
|
|
1410
1605
|
*/
|
|
1411
1606
|
function projectRootFor(options) {
|
|
1412
|
-
const from =
|
|
1607
|
+
const from = startedIn(options);
|
|
1413
1608
|
const config = options.configFile ?? findConfigFile(from);
|
|
1414
1609
|
return config ? rootForConfig(config) : from;
|
|
1415
1610
|
}
|
|
1416
1611
|
|
|
1612
|
+
/**
|
|
1613
|
+
* The folder the command was actually typed in.
|
|
1614
|
+
*
|
|
1615
|
+
* @param {CheckOptions} options
|
|
1616
|
+
* @returns {string}
|
|
1617
|
+
*/
|
|
1618
|
+
function startedIn(options) {
|
|
1619
|
+
return path.resolve(options.cwd ?? options.root ?? process.cwd());
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/**
|
|
1623
|
+
* Files that mean "this folder is a project in its own right".
|
|
1624
|
+
*
|
|
1625
|
+
* Any one of them is enough. They are the files a person points at when asked "where does
|
|
1626
|
+
* this thing live", and every one of them is what a package manager or a language's own
|
|
1627
|
+
* tooling reads as the top of a project.
|
|
1628
|
+
*/
|
|
1629
|
+
const A_PROJECT_OF_ITS_OWN = [
|
|
1630
|
+
'package.json', '.git', 'go.mod', 'Cargo.toml', 'pyproject.toml', 'pom.xml',
|
|
1631
|
+
'build.gradle', 'build.gradle.kts', 'Gemfile', 'composer.json', 'deno.json',
|
|
1632
|
+
];
|
|
1633
|
+
|
|
1634
|
+
/**
|
|
1635
|
+
* When a check is not about the folder somebody is standing in, say so.
|
|
1636
|
+
*
|
|
1637
|
+
* Settings are found by walking UP from where the command was typed, which is right: a
|
|
1638
|
+
* check run from `src/` is meant to be about the project `src/` is part of. But the same
|
|
1639
|
+
* walk reaches out of a project and into the one above it. Stand in a sub-folder that is
|
|
1640
|
+
* its own git repository with its own package.json, run a check, and the run quietly
|
|
1641
|
+
* measures the PARENT'S product and comes back clean — measured 2026-08-31, where a folder
|
|
1642
|
+
* holding `child-product` was reported on as `parent-product` with nothing said about the
|
|
1643
|
+
* swap. Somebody reading that has been handed a clean result about a product they were not
|
|
1644
|
+
* asking about, which is the exact failure this tool exists to prevent.
|
|
1645
|
+
*
|
|
1646
|
+
* So every run that is about somewhere else says which product it is about and where that
|
|
1647
|
+
* product is, and a run that stepped out of a project of its own says it loudly and says
|
|
1648
|
+
* what to do instead. Nothing is refused: reaching up is usually right, and being told what
|
|
1649
|
+
* happened is what makes it safe.
|
|
1650
|
+
*
|
|
1651
|
+
* Exported so a test can ask the question without running a whole check.
|
|
1652
|
+
*
|
|
1653
|
+
* @param {{from: string, root: string, product?: string}} where
|
|
1654
|
+
* @returns {{note: string, gap: CoverageGap|null}|null} Null when the check really is
|
|
1655
|
+
* about the folder it was typed in, which is the ordinary case.
|
|
1656
|
+
*/
|
|
1657
|
+
export function aboutSomewhereElse(where) {
|
|
1658
|
+
const from = path.resolve(where.from);
|
|
1659
|
+
const root = path.resolve(where.root);
|
|
1660
|
+
if (from === root) return null;
|
|
1661
|
+
const named = where.product ? `"${where.product}"` : path.basename(root);
|
|
1662
|
+
|
|
1663
|
+
const itsOwn = A_PROJECT_OF_ITS_OWN.filter((name) => existsSync(path.join(from, name)));
|
|
1664
|
+
if (itsOwn.length === 0) {
|
|
1665
|
+
// Ordinary and usually wanted: somebody is standing inside the project they meant. One
|
|
1666
|
+
// sentence, so a clean answer still carries the name of what it is a clean answer about.
|
|
1667
|
+
return {
|
|
1668
|
+
note: `This check was aimed at ${named}, the project at ${shortPath(root)}; you ran it from ${shortPath(from)}, which is inside it.`,
|
|
1669
|
+
gap: null,
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
const because = itsOwn.includes('.git') && itsOwn.length > 1
|
|
1674
|
+
? `it is its own git repository and has its own ${itsOwn.filter((n) => n !== '.git').join(' and ')}`
|
|
1675
|
+
: itsOwn.includes('.git')
|
|
1676
|
+
? 'it is its own git repository'
|
|
1677
|
+
: `it has its own ${itsOwn.join(' and ')}`;
|
|
1678
|
+
// "was aimed at", not "walked": this same sentence goes on the front of a blocked run,
|
|
1679
|
+
// where nothing was walked at all, and a frame that claims more than happened would be
|
|
1680
|
+
// the same kind of lie in miniature as the one it is here to stop.
|
|
1681
|
+
const note =
|
|
1682
|
+
`NOT THE FOLDER YOU ARE STANDING IN. This check was aimed at ${named}, the project at ${shortPath(root)} — ` +
|
|
1683
|
+
`not at ${shortPath(from)}, which is where you ran it. That folder is a project in its own right (${because}) ` +
|
|
1684
|
+
`and it has no Stays Fixed settings of its own, so the settings from the folder above it are what this run used. ` +
|
|
1685
|
+
`Nothing inside ${shortPath(from)} was checked, so nothing below says anything about it. ` +
|
|
1686
|
+
`Run \`staysfixed init\` there to check that project on its own.`;
|
|
1687
|
+
return {
|
|
1688
|
+
note,
|
|
1689
|
+
gap: {
|
|
1690
|
+
what: `Everything in ${shortPath(from)}, the folder this check was run from.`,
|
|
1691
|
+
why:
|
|
1692
|
+
`It is a project in its own right (${because}), and it has no Stays Fixed settings of its own. ` +
|
|
1693
|
+
`Settings are found by walking up, so the ones at ${shortPath(root)} were used and ${named} was walked instead.`,
|
|
1694
|
+
unlockedBy: `Run \`staysfixed init\` in ${shortPath(from)} to give that project its own settings, and check it from there.`,
|
|
1695
|
+
},
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1417
1699
|
/**
|
|
1418
1700
|
* How many builds keep their whole record before the old ones are thinned out.
|
|
1419
1701
|
*
|
|
@@ -1695,9 +1977,17 @@ async function openProject(options) {
|
|
|
1695
1977
|
}
|
|
1696
1978
|
|
|
1697
1979
|
await sweepAbandonedScratch();
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1980
|
+
/** @type {string} */
|
|
1981
|
+
let scratch;
|
|
1982
|
+
/** @type {string} */
|
|
1983
|
+
let evidenceDir;
|
|
1984
|
+
try {
|
|
1985
|
+
scratch = await fsp.mkdtemp(path.join(os.tmpdir(), 'staysfixed-check-'));
|
|
1986
|
+
evidenceDir = path.join(scratch, 'evidence');
|
|
1987
|
+
await fsp.mkdir(evidenceDir, { recursive: true });
|
|
1988
|
+
} catch (e) {
|
|
1989
|
+
throw noScratchFolder(e);
|
|
1990
|
+
}
|
|
1701
1991
|
// Who this belongs to, so a later run can tell an abandoned copy from one in use.
|
|
1702
1992
|
await fsp.writeFile(path.join(scratch, 'owner.json'), JSON.stringify({ pid: process.pid, at: new Date().toISOString() })).catch(() => {});
|
|
1703
1993
|
|
|
@@ -1786,6 +2076,12 @@ async function openProject(options) {
|
|
|
1786
2076
|
unlockedBy: `Fix package.json, or put the name you want in your settings file as product: '<name>'. Until then every comparison starts from nothing.`,
|
|
1787
2077
|
});
|
|
1788
2078
|
}
|
|
2079
|
+
// Which folder this answer is about, when it is not the one the command was typed in.
|
|
2080
|
+
// It goes in the coverage list as well as on the front of the summary because the list is
|
|
2081
|
+
// what a build server's table and the closing count both read, and a fact that lives on
|
|
2082
|
+
// one field somebody has to know to look for is a fact most readers never meet.
|
|
2083
|
+
const elsewhere = aboutSomewhereElse({ from: startedIn(options), root, product });
|
|
2084
|
+
if (elsewhere?.gap) gaps.push(elsewhere.gap);
|
|
1789
2085
|
const stale = await builtBeforeItsSource(root, config);
|
|
1790
2086
|
if (stale) gaps.push(stale);
|
|
1791
2087
|
if (storeTrouble.length > 0) {
|
|
@@ -1830,6 +2126,7 @@ async function openProject(options) {
|
|
|
1830
2126
|
journeys,
|
|
1831
2127
|
gaps,
|
|
1832
2128
|
storeTrouble: storeTrouble.join(' '),
|
|
2129
|
+
elsewhere: elsewhere?.note ?? '',
|
|
1833
2130
|
walk,
|
|
1834
2131
|
bootReference,
|
|
1835
2132
|
normalise,
|
|
@@ -2424,6 +2721,19 @@ function nameOfReference(reference, asked) {
|
|
|
2424
2721
|
async function exportBuild(root, reference, scratch) {
|
|
2425
2722
|
const sha = reference.gitSha;
|
|
2426
2723
|
if (!sha) return null;
|
|
2724
|
+
// A reference cut from a tree with uncommitted changes is filed under a fingerprint of
|
|
2725
|
+
// that TREE — an id like `work-76ac0155c8b9`, deliberately not the commit's — because the
|
|
2726
|
+
// files that were checked are not the files git has. Exporting the commit and calling it
|
|
2727
|
+
// "the old build" walked different code, and everything downstream believed it: an address
|
|
2728
|
+
// the record holds a real value for was walked against a build that never had it, the
|
|
2729
|
+
// silence was read as proof the address is new, and the reply said "is there now and was
|
|
2730
|
+
// not before" about a value sitting in the record on disk. Measured 2026-08-31.
|
|
2731
|
+
//
|
|
2732
|
+
// Falling back to the stored record is weaker, and the run says so. That is the same choice
|
|
2733
|
+
// this tool already makes everywhere the old build cannot be walked, and it is the honest
|
|
2734
|
+
// one: a weaker comparison you are told about beats a strong-looking comparison against
|
|
2735
|
+
// the wrong build.
|
|
2736
|
+
if (reference.dirty === true) return null;
|
|
2427
2737
|
const dir = path.join(scratch, `reference-${sha.slice(0, 12)}`);
|
|
2428
2738
|
await fsp.mkdir(dir, { recursive: true });
|
|
2429
2739
|
try {
|