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/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
|
|
|
@@ -326,20 +326,46 @@ export async function check(options = {}) {
|
|
|
326
326
|
// the word "guard" exactly zero times. A tool built to catch silent breakage must not do
|
|
327
327
|
// nothing silently.
|
|
328
328
|
const named = await guardNames(project.root);
|
|
329
|
-
if (named.length > 0
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
329
|
+
if (named.length > 0) {
|
|
330
|
+
const walked = await walkTheGuards(project.root, options);
|
|
331
|
+
if (walked.ran) {
|
|
332
|
+
// A guard that failed is a bug somebody already had, coming back. It is sealed by
|
|
333
|
+
// name — one of the five classes no agent may wave through, whatever the reason.
|
|
334
|
+
for (const bad of walked.failed) {
|
|
335
|
+
verdict.findings = [
|
|
336
|
+
...(verdict.findings ?? []),
|
|
337
|
+
/** @type {any} */ ({
|
|
338
|
+
id: `guard-${sha256(bad.name).slice(0, 6)}`,
|
|
339
|
+
title: `A bug that was already fixed is back: ${bad.name}`,
|
|
340
|
+
why: bad.message ?? 'The guard written for it does not hold any more.',
|
|
341
|
+
class: 'named guard',
|
|
342
|
+
guard: bad.name,
|
|
343
|
+
differences: [],
|
|
344
|
+
rank: 0,
|
|
345
|
+
count: 1,
|
|
346
|
+
}),
|
|
347
|
+
];
|
|
348
|
+
}
|
|
349
|
+
verdict.summary = `${walked.said} ${verdict.summary}`;
|
|
350
|
+
if (walked.failed.length > 0) verdict.ok = false;
|
|
351
|
+
} else if (verdict.coverage) {
|
|
352
|
+
verdict.coverage.gaps = [
|
|
353
|
+
...(verdict.coverage.gaps ?? []),
|
|
354
|
+
{
|
|
355
|
+
what: `${named.length} guard${named.length === 1 ? '' : 's'} written against bugs that already happened once`,
|
|
356
|
+
why: `They are sealed by name, so nothing touching one can be waved through quietly — but they were not RUN on this check: ${walked.why}`,
|
|
357
|
+
unlockedBy: 'Give the settings an address to open — `url` beside `start` in the `web` block, or `electron.binary` — and every check walks them from then on.',
|
|
358
|
+
},
|
|
359
|
+
];
|
|
360
|
+
}
|
|
339
361
|
}
|
|
340
362
|
|
|
341
363
|
/** @type {CheckOutcome} */
|
|
342
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}`;
|
|
343
369
|
// Only a run that really did reach the surface it was aimed at may say so. The
|
|
344
370
|
// confirmation is what lets a caller tell "it went there and found nothing" from
|
|
345
371
|
// "it checked something else and found nothing", and those are not the same answer.
|
|
@@ -443,7 +469,14 @@ async function settle(verdict, store, product, guards) {
|
|
|
443
469
|
verdict.findings = decided.reported;
|
|
444
470
|
verdict.accounted = decided.accounting;
|
|
445
471
|
if (verdict.blocked !== true) {
|
|
446
|
-
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;
|
|
447
480
|
// The count goes into the sentence a person and an agent both read, not into a field
|
|
448
481
|
// one of them has to know to look for.
|
|
449
482
|
if (decided.accounting.waived > 0 || decided.accounting.expiredWaivers > 0) {
|
|
@@ -471,6 +504,21 @@ async function settle(verdict, store, product, guards) {
|
|
|
471
504
|
: `NOTHING WAS ACTUALLY COMPARED. Every journey was walked on the build you have, and not one of them had anything on record from the build you were happy with, so there was nothing to hold them against. This is not a pass and not a failure — it is no answer. ${verdict.summary}`;
|
|
472
505
|
}
|
|
473
506
|
|
|
507
|
+
// THE CAUSE FIRST, when the cause is that the product never answered.
|
|
508
|
+
//
|
|
509
|
+
// A server that will not start produces a difference at every address it used to answer
|
|
510
|
+
// at — the content type gone, the body gone, "answered at all" arriving. Measured
|
|
511
|
+
// 2026-08-31 on a product whose start command throws: twelve findings, not one of them
|
|
512
|
+
// saying the server had not started, and a person reads "12 things behave differently"
|
|
513
|
+
// and goes looking for a regression in code that is fine. The symptoms are real and they
|
|
514
|
+
// belong in the list; they are just not the news.
|
|
515
|
+
const silent = didNotAnswer(verdict);
|
|
516
|
+
if (silent.length > 0) {
|
|
517
|
+
verdict.summary =
|
|
518
|
+
`THE PRODUCT DID NOT ANSWER. ${silent.length} ${silent.length === 1 ? 'way in was' : 'ways in were'} not tried at all — ${silent.slice(0, 3).join('; ')}${silent.length > 3 ? '; and more' : ''}. Most of what follows is that one fact wearing different clothes, not ${silent.length === 1 ? 'a separate change' : 'separate changes'}: fix the start and check again before reading any of it as a regression. ` +
|
|
519
|
+
verdict.summary;
|
|
520
|
+
}
|
|
521
|
+
|
|
474
522
|
// And what was NOT looked at, in the same breath as the good news, on every run
|
|
475
523
|
// including the clean ones. A green verdict on a product with three hundred doors
|
|
476
524
|
// nobody has ever opened is true and it is not what it looks like, and the only place
|
|
@@ -620,7 +668,11 @@ export function whatWasNotChecked(coverage) {
|
|
|
620
668
|
if (parts.length === 0) {
|
|
621
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.`;
|
|
622
670
|
}
|
|
623
|
-
|
|
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.`;
|
|
624
676
|
}
|
|
625
677
|
|
|
626
678
|
/**
|
|
@@ -659,6 +711,45 @@ function comparedNothing(verdict) {
|
|
|
659
711
|
return nothingToCompare >= walked ? 'no stored record' : null;
|
|
660
712
|
}
|
|
661
713
|
|
|
714
|
+
/**
|
|
715
|
+
* Walk the guards, if this project can be opened.
|
|
716
|
+
*
|
|
717
|
+
* Guards are the headline of this whole product — one plain-English rule per bug somebody
|
|
718
|
+
* already had — and the default command never ran them. It said so, which was better than
|
|
719
|
+
* pretending, but saying it is not doing it. Version 1 knows how to drive an app and run
|
|
720
|
+
* them; what was missing was anybody calling it from here.
|
|
721
|
+
*
|
|
722
|
+
* It needs an address it can open. Where the settings only say how to START the product,
|
|
723
|
+
* that is version 2's job and this path cannot do it — so it says so instead, and names the
|
|
724
|
+
* one line that would change it.
|
|
725
|
+
*
|
|
726
|
+
* @param {string} root
|
|
727
|
+
* @param {any} options
|
|
728
|
+
* @returns {Promise<{ran: boolean, why: string, said: string, failed: {name: string, message?: string}[]}>}
|
|
729
|
+
*/
|
|
730
|
+
async function walkTheGuards(root, options) {
|
|
731
|
+
try {
|
|
732
|
+
const { loadProject } = await import('../core/config.js');
|
|
733
|
+
const { runCheck } = await import('../run.js');
|
|
734
|
+
const project = await loadProject({ cwd: root, configFile: options?.configFile });
|
|
735
|
+
const run = await runCheck(project, { guardsOnly: true, writeReport: false, quiet: true, signal: options?.signal });
|
|
736
|
+
const guards = run.guards ?? [];
|
|
737
|
+
const failed = guards.filter((g) => g.status === 'failed').map((g) => ({ name: g.name, message: g.message }));
|
|
738
|
+
const held = guards.length - failed.length;
|
|
739
|
+
return {
|
|
740
|
+
ran: true,
|
|
741
|
+
why: '',
|
|
742
|
+
said:
|
|
743
|
+
failed.length === 0
|
|
744
|
+
? `All ${guards.length} guard${guards.length === 1 ? '' : 's'} still hold.`
|
|
745
|
+
: `${failed.length} of ${guards.length} guards failed — ${failed.length === 1 ? 'a bug' : 'bugs'} that ${failed.length === 1 ? 'was' : 'were'} already fixed ${failed.length === 1 ? 'is' : 'are'} back, and no agent may wave ${failed.length === 1 ? 'it' : 'them'} through. ${held} still hold.`,
|
|
746
|
+
failed,
|
|
747
|
+
};
|
|
748
|
+
} catch (e) {
|
|
749
|
+
return { ran: false, why: messageOf(e), said: '', failed: [] };
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
662
753
|
/**
|
|
663
754
|
* The guards this project has, by name.
|
|
664
755
|
*
|
|
@@ -681,6 +772,125 @@ export async function guardNames(root) {
|
|
|
681
772
|
}
|
|
682
773
|
}
|
|
683
774
|
|
|
775
|
+
/**
|
|
776
|
+
* Is the thing that ran older than the code it was built from?
|
|
777
|
+
*
|
|
778
|
+
* This tool never builds anything — deliberately, because building somebody's project is not
|
|
779
|
+
* its business. But a project whose start command runs `dist/server.js` and whose source
|
|
780
|
+
* lives in `src/` will happily run YESTERDAY's build against today's source, compare it
|
|
781
|
+
* against a reference cut from the same stale output, and answer "Nothing that worked has
|
|
782
|
+
* changed" — about code it has never once executed. Measured 2026-08-31.
|
|
783
|
+
*
|
|
784
|
+
* It cannot be fixed by building; it can be SAID, which is all a coverage gap has to do.
|
|
785
|
+
*
|
|
786
|
+
* @param {string} root
|
|
787
|
+
* @param {any} config
|
|
788
|
+
* @returns {Promise<CoverageGap|null>}
|
|
789
|
+
*/
|
|
790
|
+
async function builtBeforeItsSource(root, config) {
|
|
791
|
+
try {
|
|
792
|
+
let pkg = {};
|
|
793
|
+
try {
|
|
794
|
+
pkg = JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8'));
|
|
795
|
+
} catch {
|
|
796
|
+
return null;
|
|
797
|
+
}
|
|
798
|
+
const scripts = /** @type {any} */ (pkg).scripts ?? {};
|
|
799
|
+
// `npm run start` says nothing about where the product lives; the answer is one level
|
|
800
|
+
// down, in the script it runs. Following that indirection is the difference between this
|
|
801
|
+
// check firing and never firing, because `init` writes exactly `npm run start`.
|
|
802
|
+
const through = (/** @type {string} */ line) => {
|
|
803
|
+
const run = /(?:npm run|yarn|pnpm run|pnpm)\s+([\w:-]+)/.exec(line);
|
|
804
|
+
const named = run ? scripts[run[1]] : line.includes('npm start') ? scripts.start : null;
|
|
805
|
+
return `${line} ${typeof named === 'string' ? named : ''}`;
|
|
806
|
+
};
|
|
807
|
+
const starts = [
|
|
808
|
+
...(config?.process?.commands ?? []).map((/** @type {any} */ c) => String(c?.run ?? '')),
|
|
809
|
+
String(config?.http?.start ?? ''),
|
|
810
|
+
String(config?.web?.start ?? ''),
|
|
811
|
+
].map(through).join(' ');
|
|
812
|
+
const named = /\b(dist|build|out|lib)\b/.exec(starts);
|
|
813
|
+
const builds = typeof scripts.build === 'string';
|
|
814
|
+
if (!named || !builds) return null;
|
|
815
|
+
|
|
816
|
+
const outDir = path.join(root, named[1]);
|
|
817
|
+
const srcDir = path.join(root, 'src');
|
|
818
|
+
const [built, source] = await Promise.all([newestUnder(outDir), newestUnder(srcDir)]);
|
|
819
|
+
if (built === 0 || source === 0 || source <= built) return null;
|
|
820
|
+
|
|
821
|
+
const behind = Math.round((source - built) / 1000);
|
|
822
|
+
/** @param {number} n @param {string} unit */
|
|
823
|
+
const plural = (n, unit) => `${n} ${unit}${n === 1 ? '' : 's'}`;
|
|
824
|
+
const howLong =
|
|
825
|
+
behind > 86400 ? plural(Math.round(behind / 86400), 'day') : behind > 3600 ? plural(Math.round(behind / 3600), 'hour') : plural(Math.max(1, Math.round(behind / 60)), 'minute');
|
|
826
|
+
return {
|
|
827
|
+
what: `What ran is older than the code it was built from — \`${named[1]}/\` is ${howLong} behind \`src/\`.`,
|
|
828
|
+
why: `This tool runs your product, it never builds it. So the build in \`${named[1]}/\` is what was walked, and your newer source was not executed at all. A clean result here says nothing whatever about the code you have just written — and the reference it was compared against was cut from the same stale output.`,
|
|
829
|
+
unlockedBy: 'Run your build before the check — `npm run build && npx staysfixed check` — or put the build into the start command in your settings.',
|
|
830
|
+
};
|
|
831
|
+
} catch {
|
|
832
|
+
return null;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* The newest modification time anywhere under a folder, or 0 if there is nothing there.
|
|
838
|
+
* @param {string} dir
|
|
839
|
+
* @returns {Promise<number>}
|
|
840
|
+
*/
|
|
841
|
+
async function newestUnder(dir) {
|
|
842
|
+
let newest = 0;
|
|
843
|
+
/** @param {string} at @param {number} depth */
|
|
844
|
+
const walk = async (at, depth) => {
|
|
845
|
+
if (depth > 6) return;
|
|
846
|
+
let entries = [];
|
|
847
|
+
try {
|
|
848
|
+
entries = await fsp.readdir(at, { withFileTypes: true });
|
|
849
|
+
} catch {
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
for (const entry of entries) {
|
|
853
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
|
|
854
|
+
const full = path.join(at, entry.name);
|
|
855
|
+
if (entry.isDirectory()) await walk(full, depth + 1);
|
|
856
|
+
else {
|
|
857
|
+
try {
|
|
858
|
+
const at2 = (await fsp.stat(full)).mtimeMs;
|
|
859
|
+
if (at2 > newest) newest = at2;
|
|
860
|
+
} catch {
|
|
861
|
+
// gone between the listing and the question
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
};
|
|
866
|
+
await walk(dir, 0);
|
|
867
|
+
return newest;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* The ways into the product that were never tried, and why.
|
|
872
|
+
*
|
|
873
|
+
* A build that would not start does not produce one finding — it produces one at every
|
|
874
|
+
* address it used to answer at, and the real news is nowhere in the list.
|
|
875
|
+
*
|
|
876
|
+
* @param {CheckOutcome} verdict
|
|
877
|
+
* @returns {string[]}
|
|
878
|
+
*/
|
|
879
|
+
function didNotAnswer(verdict) {
|
|
880
|
+
/** @type {Set<string>} */
|
|
881
|
+
const said = new Set();
|
|
882
|
+
for (const finding of verdict.findings ?? []) {
|
|
883
|
+
for (const d of finding.differences ?? []) {
|
|
884
|
+
const path = String(d.path ?? '');
|
|
885
|
+
if (!path.endsWith('answered at all')) continue;
|
|
886
|
+
const name = path.split('.').slice(1, -1).join('.') || path;
|
|
887
|
+
const why = typeof d.candidate === 'string' ? d.candidate : typeof d.describe === 'string' ? d.describe : '';
|
|
888
|
+
said.add(why ? `${name} (${String(why).slice(0, 90)})` : name);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
return [...said];
|
|
892
|
+
}
|
|
893
|
+
|
|
684
894
|
/**
|
|
685
895
|
* Copies left behind by runs that never finished.
|
|
686
896
|
*
|
|
@@ -707,6 +917,13 @@ export async function sweepAbandonedScratch() {
|
|
|
707
917
|
} catch {
|
|
708
918
|
return;
|
|
709
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
|
+
|
|
710
927
|
let taken = 0;
|
|
711
928
|
for (const name of names) {
|
|
712
929
|
if (taken >= MOST_PER_RUN) break;
|
|
@@ -726,11 +943,168 @@ export async function sweepAbandonedScratch() {
|
|
|
726
943
|
}
|
|
727
944
|
}
|
|
728
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);
|
|
729
953
|
await fsp.rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
730
954
|
taken += 1;
|
|
731
955
|
}
|
|
732
956
|
}
|
|
733
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
|
+
|
|
734
1108
|
/**
|
|
735
1109
|
* Is that process still running? Signal 0 asks without sending anything.
|
|
736
1110
|
* @param {number} pid
|
|
@@ -766,8 +1140,20 @@ function plainly(what) {
|
|
|
766
1140
|
* @returns {CheckOutcome}
|
|
767
1141
|
*/
|
|
768
1142
|
function blocked(options, e, storeTrouble) {
|
|
769
|
-
|
|
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);
|
|
770
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 ?? '';
|
|
771
1157
|
return {
|
|
772
1158
|
runId: new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14),
|
|
773
1159
|
product,
|
|
@@ -790,7 +1176,7 @@ function blocked(options, e, storeTrouble) {
|
|
|
790
1176
|
// The hint is the half that tells a person what to DO about it, and dropping it
|
|
791
1177
|
// turns a helpful error into a dead end. Anything that blocks a run has to carry
|
|
792
1178
|
// both halves all the way out to whoever reads the summary.
|
|
793
|
-
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)}${
|
|
794
1180
|
e instanceof Error && /** @type {any} */ (e).hint ? ` ${/** @type {any} */ (e).hint}` : ''
|
|
795
1181
|
}`,
|
|
796
1182
|
durationMs: 0,
|
|
@@ -1198,6 +1584,10 @@ async function waitForItsWindow(pid, stopped) {
|
|
|
1198
1584
|
* @property {string} storeTrouble Empty on a normal run. A plain sentence when the store
|
|
1199
1585
|
* would not take this run's records — the run went ahead anyway, and every answer it
|
|
1200
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.
|
|
1201
1591
|
* @property {import('./run.js').Walker} walk
|
|
1202
1592
|
* @property {(reference: BuildFingerprint, ctx: {events?: CheckEvents, signal?: AbortSignal}) => Promise<LiveBuild|null>} bootReference
|
|
1203
1593
|
* @property {(capture: Capture) => Capture} normalise
|
|
@@ -1214,11 +1604,98 @@ async function waitForItsWindow(pid, stopped) {
|
|
|
1214
1604
|
* @returns {string}
|
|
1215
1605
|
*/
|
|
1216
1606
|
function projectRootFor(options) {
|
|
1217
|
-
const from =
|
|
1607
|
+
const from = startedIn(options);
|
|
1218
1608
|
const config = options.configFile ?? findConfigFile(from);
|
|
1219
1609
|
return config ? rootForConfig(config) : from;
|
|
1220
1610
|
}
|
|
1221
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
|
+
|
|
1222
1699
|
/**
|
|
1223
1700
|
* How many builds keep their whole record before the old ones are thinned out.
|
|
1224
1701
|
*
|
|
@@ -1500,9 +1977,17 @@ async function openProject(options) {
|
|
|
1500
1977
|
}
|
|
1501
1978
|
|
|
1502
1979
|
await sweepAbandonedScratch();
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
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
|
+
}
|
|
1506
1991
|
// Who this belongs to, so a later run can tell an abandoned copy from one in use.
|
|
1507
1992
|
await fsp.writeFile(path.join(scratch, 'owner.json'), JSON.stringify({ pid: process.pid, at: new Date().toISOString() })).catch(() => {});
|
|
1508
1993
|
|
|
@@ -1591,6 +2076,14 @@ async function openProject(options) {
|
|
|
1591
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.`,
|
|
1592
2077
|
});
|
|
1593
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);
|
|
2085
|
+
const stale = await builtBeforeItsSource(root, config);
|
|
2086
|
+
if (stale) gaps.push(stale);
|
|
1594
2087
|
if (storeTrouble.length > 0) {
|
|
1595
2088
|
gaps.push({
|
|
1596
2089
|
what: 'This run was NOT written down, so the next check has nothing from today to compare against.',
|
|
@@ -1633,6 +2126,7 @@ async function openProject(options) {
|
|
|
1633
2126
|
journeys,
|
|
1634
2127
|
gaps,
|
|
1635
2128
|
storeTrouble: storeTrouble.join(' '),
|
|
2129
|
+
elsewhere: elsewhere?.note ?? '',
|
|
1636
2130
|
walk,
|
|
1637
2131
|
bootReference,
|
|
1638
2132
|
normalise,
|
|
@@ -2227,6 +2721,19 @@ function nameOfReference(reference, asked) {
|
|
|
2227
2721
|
async function exportBuild(root, reference, scratch) {
|
|
2228
2722
|
const sha = reference.gitSha;
|
|
2229
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;
|
|
2230
2737
|
const dir = path.join(scratch, `reference-${sha.slice(0, 12)}`);
|
|
2231
2738
|
await fsp.mkdir(dir, { recursive: true });
|
|
2232
2739
|
try {
|