staysfixed 0.4.0 → 0.6.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 +159 -3
- package/README.md +83 -6
- package/package.json +1 -1
- package/src/v2/adapters/contract.js +84 -6
- package/src/v2/adapters/electron.js +5 -5
- package/src/v2/adapters/http.js +7 -6
- package/src/v2/adapters/process.js +17 -6
- package/src/v2/adapters/source.js +62 -11
- package/src/v2/adapters/web.js +6 -6
- package/src/v2/cause.js +15 -8
- package/src/v2/check.js +121 -23
- package/src/v2/cli.js +16 -3
- package/src/v2/coverage.js +9 -1
- package/src/v2/detect.js +1 -1
- package/src/v2/doctor.js +15 -3
- package/src/v2/journeys/from-routes.js +3 -1
- package/src/v2/observation.js +62 -1
- package/src/v2/remote.js +4 -5
- package/src/v2/run.js +125 -9
- package/src/v2/sealed.js +10 -6
- package/src/v2/selfcheck.js +197 -32
- package/src/v2/types.js +6 -0
- package/src/v2/watch/focus.js +215 -0
|
@@ -748,12 +748,16 @@ function methodNames(tokens, nameAt) {
|
|
|
748
748
|
* @param {string} opts.root Project root. Only ever read.
|
|
749
749
|
* @param {string[]} [opts.folders] Subfolders to read. Defaults to the usual ones.
|
|
750
750
|
* @param {boolean} [opts.includeTests] Count doors found in test files. Default false.
|
|
751
|
-
* @param {number} [opts.maxFileBytes] Skip anything bigger. Default
|
|
751
|
+
* @param {number} [opts.maxFileBytes] Skip anything bigger. Default 24MB — a built
|
|
752
|
+
* bundle is a legitimate thing to read, and Terminal Deck's is 3.5MB. At the old 2MB
|
|
753
|
+
* the whole main process was skipped and the reader then said it had found no source
|
|
754
|
+
* at all, which is the exact shape of failure this tool exists to prevent: a silence
|
|
755
|
+
* that reads like an all-clear.
|
|
752
756
|
* @returns {Promise<ContractReading>}
|
|
753
757
|
*/
|
|
754
758
|
export async function readContract(opts) {
|
|
755
759
|
const root = path.resolve(opts.root);
|
|
756
|
-
const maxFileBytes = opts.maxFileBytes ??
|
|
760
|
+
const maxFileBytes = opts.maxFileBytes ?? 24 * 1024 * 1024;
|
|
757
761
|
const found = await collectFiles(root, opts.folders ?? SOURCE_FOLDERS, maxFileBytes);
|
|
758
762
|
|
|
759
763
|
/** @type {RawDoor[]} */
|
|
@@ -767,6 +771,12 @@ export async function readContract(opts) {
|
|
|
767
771
|
filesRead: 0, filesSkipped: found.skipped, testFiles: 0, lexRecoveries: 0,
|
|
768
772
|
typesStripped: 0, unnamed: 0, viaConstant: 0, duplicates: 0, problems: [], counts: {},
|
|
769
773
|
};
|
|
774
|
+
for (const where of found.unreadable) {
|
|
775
|
+
report.problems.push(`${where} could not be opened, so any door behind it is invisible to this run.`);
|
|
776
|
+
}
|
|
777
|
+
for (const big of found.tooBig) {
|
|
778
|
+
report.problems.push(`${big} is bigger than the ${Math.round(maxFileBytes / (1024 * 1024))}MB this reader will open, so its doors were not read.`);
|
|
779
|
+
}
|
|
770
780
|
|
|
771
781
|
for (const rel of found.files) {
|
|
772
782
|
let text;
|
|
@@ -854,6 +864,10 @@ function describeError(error) {
|
|
|
854
864
|
* @param {number} maxFileBytes
|
|
855
865
|
*/
|
|
856
866
|
async function collectFiles(root, folders, maxFileBytes) {
|
|
867
|
+
/** @type {string[]} Files skipped for size, named so the gap can be reported. */
|
|
868
|
+
const tooBig = [];
|
|
869
|
+
/** @type {string[]} A folder that could not be opened at all, named for the same reason. */
|
|
870
|
+
const unreadable = [];
|
|
857
871
|
/** @type {string[]} */
|
|
858
872
|
const files = [];
|
|
859
873
|
let skipped = 0;
|
|
@@ -864,7 +878,12 @@ async function collectFiles(root, folders, maxFileBytes) {
|
|
|
864
878
|
let entries;
|
|
865
879
|
try {
|
|
866
880
|
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
867
|
-
} catch {
|
|
881
|
+
} catch (e) {
|
|
882
|
+
// A folder that will not open — a permission, a broken mount, a case-clash — used to
|
|
883
|
+
// vanish without a word, and every door behind it vanished with it. That is the same
|
|
884
|
+
// bug as the 2MB file limit wearing a different hat: fewer doors reported, and nothing
|
|
885
|
+
// anywhere saying so. Name it.
|
|
886
|
+
unreadable.push(`${path.relative(root, dir) || '.'} (${describeError(e)})`);
|
|
868
887
|
return;
|
|
869
888
|
}
|
|
870
889
|
for (const entry of entries) {
|
|
@@ -879,8 +898,16 @@ async function collectFiles(root, folders, maxFileBytes) {
|
|
|
879
898
|
if (!CODE_EXTENSIONS.has(path.extname(entry.name))) continue;
|
|
880
899
|
if (/\.d\.[cm]?ts$/.test(entry.name)) continue; // declarations describe, they open nothing
|
|
881
900
|
try {
|
|
882
|
-
|
|
883
|
-
|
|
901
|
+
// A file too big to read is a hole, and a hole has to be named. Recording the
|
|
902
|
+
// path — not just a count — is what lets the coverage ledger say WHICH door it
|
|
903
|
+
// cannot see rather than quietly reporting fewer of them.
|
|
904
|
+
if ((await fsp.stat(full)).size > maxFileBytes) {
|
|
905
|
+
skipped++;
|
|
906
|
+
tooBig.push(path.relative(root, full));
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
} catch (e) {
|
|
910
|
+
unreadable.push(`${path.relative(root, full)} (${describeError(e)})`);
|
|
884
911
|
continue;
|
|
885
912
|
}
|
|
886
913
|
files.push(path.relative(root, full));
|
|
@@ -890,7 +917,11 @@ async function collectFiles(root, folders, maxFileBytes) {
|
|
|
890
917
|
const roots = folders.map((f) => path.join(root, f)).filter((d) => fs.existsSync(d));
|
|
891
918
|
for (const dir of roots.length > 0 ? roots : [root]) await walk(dir);
|
|
892
919
|
files.sort();
|
|
893
|
-
|
|
920
|
+
// A source folder that was asked for and is not there at all is worth saying too: a typo in
|
|
921
|
+
// "folders" reads exactly like a project with no code in it.
|
|
922
|
+
const asked = folders.map((f) => path.join(root, f));
|
|
923
|
+
const present = asked.filter((d) => fs.existsSync(d));
|
|
924
|
+
return { files, skipped, tooBig, unreadable, lookedIn: present.length > 0 ? present.map((d) => path.relative(root, d)) : ['the whole project folder'] };
|
|
894
925
|
}
|
|
895
926
|
|
|
896
927
|
// ---------------------------------------------------------------------------
|
|
@@ -902,12 +933,18 @@ async function collectFiles(root, folders, maxFileBytes) {
|
|
|
902
933
|
* Both layouts are handled: an app folder, where a `route` file's exported method names are
|
|
903
934
|
* the verbs, and a pages/api folder, where the file itself is the route.
|
|
904
935
|
*
|
|
936
|
+
* A folder that cannot be opened takes every route behind it, so it is named rather than
|
|
937
|
+
* skipped. This is the same bug as the one fixed in the file walk on 2026-08-30 — a hole that
|
|
938
|
+
* looks exactly like a project with no routes in it — and it was still here in this function.
|
|
939
|
+
*
|
|
905
940
|
* @param {string} root
|
|
906
|
-
* @returns {Promise<Door[]>}
|
|
941
|
+
* @returns {Promise<{doors: Door[], problems: string[]}>}
|
|
907
942
|
*/
|
|
908
943
|
export async function readFileRoutes(root) {
|
|
909
944
|
/** @type {Door[]} */
|
|
910
945
|
const doors = [];
|
|
946
|
+
/** @type {string[]} */
|
|
947
|
+
const problems = [];
|
|
911
948
|
|
|
912
949
|
/**
|
|
913
950
|
* @param {string} base
|
|
@@ -921,7 +958,12 @@ export async function readFileRoutes(root) {
|
|
|
921
958
|
const dir = /** @type {string} */ (stack.pop());
|
|
922
959
|
/** @type {import('node:fs').Dirent[]} */
|
|
923
960
|
let entries;
|
|
924
|
-
try {
|
|
961
|
+
try {
|
|
962
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
963
|
+
} catch (e) {
|
|
964
|
+
problems.push(`${path.relative(root, dir) || '.'} could not be opened, so any route behind it is invisible to this run (${describeError(e)}).`);
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
925
967
|
for (const entry of entries) {
|
|
926
968
|
const full = path.join(dir, entry.name);
|
|
927
969
|
if (entry.isDirectory()) {
|
|
@@ -972,7 +1014,7 @@ export async function readFileRoutes(root) {
|
|
|
972
1014
|
});
|
|
973
1015
|
}
|
|
974
1016
|
|
|
975
|
-
return doors;
|
|
1017
|
+
return { doors, problems };
|
|
976
1018
|
}
|
|
977
1019
|
|
|
978
1020
|
/**
|
|
@@ -1162,7 +1204,10 @@ export const sourceAdapter = defineAdapter({
|
|
|
1162
1204
|
/** @param {import('./contract.js').AdapterProject} project */
|
|
1163
1205
|
async detect(project) {
|
|
1164
1206
|
const folders = project.config?.folders ?? SOURCE_FOLDERS;
|
|
1165
|
-
|
|
1207
|
+
// The same limit readContract uses. It was 2MB here and 24MB there, so a project
|
|
1208
|
+
// whose source is one big bundle was declared to have no source at all — and then
|
|
1209
|
+
// never read, even though the reader could have read it perfectly well.
|
|
1210
|
+
const found = await collectFiles(project.root, folders, 24 * 1024 * 1024);
|
|
1166
1211
|
const canStrip = typeof nodeModule.stripTypeScriptTypes === 'function';
|
|
1167
1212
|
/** @type {import('./contract.js').Missing[]} */
|
|
1168
1213
|
const missing = [];
|
|
@@ -1214,7 +1259,13 @@ export const sourceAdapter = defineAdapter({
|
|
|
1214
1259
|
*/
|
|
1215
1260
|
async run(journey, build) {
|
|
1216
1261
|
const reading = await readContract({ root: build.root });
|
|
1217
|
-
|
|
1262
|
+
const fileRoutes = await readFileRoutes(build.root);
|
|
1263
|
+
reading.doors.push(...fileRoutes.doors);
|
|
1264
|
+
// A folder the route walk could not open is a hole in the door list, and the door list is
|
|
1265
|
+
// the channel that catches doors disappearing. It goes where every other reading problem
|
|
1266
|
+
// goes: into the report, which becomes an observation of its own, so it shows up as a
|
|
1267
|
+
// difference the moment it starts or stops happening.
|
|
1268
|
+
reading.report.problems.push(...fileRoutes.problems);
|
|
1218
1269
|
reading.doors.push(...await readPackageCommands(build.root));
|
|
1219
1270
|
reading.report.counts = {};
|
|
1220
1271
|
for (const found of reading.doors) {
|
package/src/v2/adapters/web.js
CHANGED
|
@@ -42,8 +42,8 @@ import path from 'node:path';
|
|
|
42
42
|
import { spawn } from 'node:child_process';
|
|
43
43
|
|
|
44
44
|
import {
|
|
45
|
-
countBucket, defineAdapter, joinPath, notCovered, observation, sizeBucket,
|
|
46
|
-
trimForStorage, undoOurFootprint,
|
|
45
|
+
countBucket, defineAdapter, howLongItTook, joinPath, notCovered, observation, sizeBucket,
|
|
46
|
+
timeBucket, trimForStorage, undoOurFootprint,
|
|
47
47
|
} from './contract.js';
|
|
48
48
|
import { copyForScratch, frozenEnvironment } from './process.js';
|
|
49
49
|
import { freePort, looksDestructive, waitForServer } from './http.js';
|
|
@@ -624,13 +624,13 @@ export const webAdapter = defineAdapter({
|
|
|
624
624
|
out.push(...describeComplaints(journey, handle.consoleErrors()));
|
|
625
625
|
|
|
626
626
|
out.push(
|
|
627
|
-
|
|
627
|
+
howLongItTook({
|
|
628
628
|
channel: 'counters',
|
|
629
629
|
path: joinPath('count', journey.name, 'how long the steps took'),
|
|
630
|
-
|
|
631
|
-
|
|
630
|
+
ms: Date.now() - started,
|
|
631
|
+
what: `Walking the steps of "${journey.name}"`,
|
|
632
|
+
andAlso: 'This does not count opening the browser, which is our time and not the app\'s.',
|
|
632
633
|
journey: journey.name,
|
|
633
|
-
surface: 'web',
|
|
634
634
|
}),
|
|
635
635
|
);
|
|
636
636
|
|
package/src/v2/cause.js
CHANGED
|
@@ -60,12 +60,17 @@ const run = promisify(execFile);
|
|
|
60
60
|
* @property {string} [worktree] Where it ran, when `keep` was asked for.
|
|
61
61
|
*/
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
// EVERY difference in the finding is re-checked, and there is deliberately no ceiling.
|
|
64
|
+
//
|
|
65
|
+
// Until 2026-08-30 this checked the first five and then said "caused by that change" about
|
|
66
|
+
// the whole finding. A cluster of three hundred addresses where the first five went away and
|
|
67
|
+
// the other two hundred and ninety-five did not came back PROVED — the agent then had a
|
|
68
|
+
// machine-checked reason to wave the whole thing through, and the rest of the break went with
|
|
69
|
+
// it. That is this tool's worst failure shape: a confident sentence covering a silence.
|
|
70
|
+
//
|
|
71
|
+
// The comment that used to sit here said walking them all buys nothing. It buys the claim.
|
|
72
|
+
// And it costs nothing to buy: the journeys were re-walked already, the observations are in
|
|
73
|
+
// the map below, and each extra difference is one lookup in it.
|
|
69
74
|
|
|
70
75
|
/**
|
|
71
76
|
* Prove, or disprove, that one change caused one finding.
|
|
@@ -192,7 +197,7 @@ export async function proveCause(finding, opts) {
|
|
|
192
197
|
without.set(journey.name, indexByPath(settled.observations));
|
|
193
198
|
}
|
|
194
199
|
|
|
195
|
-
const differences = finding.differences
|
|
200
|
+
const differences = finding.differences;
|
|
196
201
|
let disappeared = 0;
|
|
197
202
|
for (const d of differences) if (gone(d, without)) disappeared += 1;
|
|
198
203
|
|
|
@@ -207,7 +212,9 @@ export async function proveCause(finding, opts) {
|
|
|
207
212
|
? 'This finding carries no differences, so there was nothing to re-check.'
|
|
208
213
|
: proved
|
|
209
214
|
? `Undoing that one change in ${hunk.file} made this go away. It is yours, and it is explained.`
|
|
210
|
-
:
|
|
215
|
+
: disappeared > 0
|
|
216
|
+
? `Undoing that change in ${hunk.file} took away ${disappeared} of the ${differences.length} addresses in this finding and left ${differences.length - disappeared} exactly as ${differences.length - disappeared === 1 ? 'it was' : 'they were'}. So that change explains part of this and not the rest, and the rest has another cause nothing has looked for yet. It is not covered by undoing that one change.`
|
|
217
|
+
: `This is still here with that change undone, so ${hunk.file} is not what caused it. Something else did, and nothing knows what yet.`,
|
|
211
218
|
hunk: { file: hunk.file, header: hunk.header },
|
|
212
219
|
checked: differences.length,
|
|
213
220
|
disappeared,
|
package/src/v2/check.js
CHANGED
|
@@ -27,7 +27,8 @@ import fsp from 'node:fs/promises';
|
|
|
27
27
|
import { existsSync } from 'node:fs';
|
|
28
28
|
import path from 'node:path';
|
|
29
29
|
import os from 'node:os';
|
|
30
|
-
import { execFile } from 'node:child_process';
|
|
30
|
+
import { execFile, spawn } from 'node:child_process';
|
|
31
|
+
import { createHash } from 'node:crypto';
|
|
31
32
|
import { promisify } from 'node:util';
|
|
32
33
|
|
|
33
34
|
import { StaysFixedError, messageOf } from '../core/errors.js';
|
|
@@ -270,6 +271,7 @@ export async function check(options = {}) {
|
|
|
270
271
|
product: project.product,
|
|
271
272
|
candidate: project.candidate,
|
|
272
273
|
journeys: project.journeys,
|
|
274
|
+
gaps: project.gaps,
|
|
273
275
|
walk: project.walk,
|
|
274
276
|
cwd: project.root,
|
|
275
277
|
bootReference: project.bootReference,
|
|
@@ -695,6 +697,10 @@ function short(value) {
|
|
|
695
697
|
* @property {BuildFingerprint} candidate
|
|
696
698
|
* @property {string} [against] The reference build's own id, once a name has been resolved.
|
|
697
699
|
* @property {Journey[]} journeys
|
|
700
|
+
* @property {CoverageGap[]} gaps Holes found while working out WHAT to walk, before a
|
|
701
|
+
* single journey ran. An adapter that fell over listing its journeys belongs here, and it
|
|
702
|
+
* has to reach the verdict: a channel that silently dropped out is the worst thing this
|
|
703
|
+
* tool can do.
|
|
698
704
|
* @property {import('./run.js').Walker} walk
|
|
699
705
|
* @property {(reference: BuildFingerprint, ctx: {events?: CheckEvents, signal?: AbortSignal}) => Promise<LiveBuild|null>} bootReference
|
|
700
706
|
* @property {(capture: Capture) => Capture} normalise
|
|
@@ -732,17 +738,11 @@ async function openProject(options) {
|
|
|
732
738
|
const evidenceDir = path.join(scratch, 'evidence');
|
|
733
739
|
await fsp.mkdir(evidenceDir, { recursive: true });
|
|
734
740
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
// what lets a check be aimed at any point in history without every commit having been
|
|
741
|
-
// walked before. Without it "HEAD" matches nothing and the check reports itself blocked.
|
|
742
|
-
const reference = options.against ? await fingerprintCommit(root, product, options.against) : null;
|
|
743
|
-
if (reference) await saveBuild(store, reference);
|
|
744
|
-
|
|
745
|
-
const journeys = narrowToTarget(await gatherJourneys({ root, config, options }), aim);
|
|
741
|
+
// Working out what there is to walk comes FIRST, before anything is asked of git. Somebody
|
|
742
|
+
// standing in a folder they have not set up yet should be told to run `init`, not told
|
|
743
|
+
// about a git requirement they have no reason to care about yet.
|
|
744
|
+
const gathered = await gatherJourneys({ root, config, options });
|
|
745
|
+
const journeys = narrowToTarget(gathered.journeys, aim);
|
|
746
746
|
if (journeys.length === 0) {
|
|
747
747
|
await fsp.rm(scratch, { recursive: true, force: true });
|
|
748
748
|
// Two different situations wear the same symptom, and the difference is the
|
|
@@ -762,6 +762,16 @@ async function openProject(options) {
|
|
|
762
762
|
});
|
|
763
763
|
}
|
|
764
764
|
|
|
765
|
+
const candidate = await fingerprintWorkingTree(root, product);
|
|
766
|
+
await saveBuild(store, candidate);
|
|
767
|
+
|
|
768
|
+
// A name like "HEAD", "v0.13.0" or a branch is what a person types; the store only knows
|
|
769
|
+
// builds. Turning the name into a commit here, and putting that commit in the store, is
|
|
770
|
+
// what lets a check be aimed at any point in history without every commit having been
|
|
771
|
+
// walked before. Without it "HEAD" matches nothing and the check reports itself blocked.
|
|
772
|
+
const reference = options.against ? await fingerprintCommit(root, product, options.against) : null;
|
|
773
|
+
if (reference) await saveBuild(store, reference);
|
|
774
|
+
|
|
765
775
|
const rules = mergeRules(DEFAULT_RULES, [
|
|
766
776
|
...machineRules({ root, home: os.homedir(), tmp: os.tmpdir() }),
|
|
767
777
|
...machineRules({ root: scratch }),
|
|
@@ -793,6 +803,7 @@ async function openProject(options) {
|
|
|
793
803
|
candidate,
|
|
794
804
|
against: reference ? reference.id : options.against,
|
|
795
805
|
journeys,
|
|
806
|
+
gaps: gathered.gaps,
|
|
796
807
|
walk,
|
|
797
808
|
bootReference,
|
|
798
809
|
normalise,
|
|
@@ -1063,11 +1074,13 @@ function adapterFor(journey) {
|
|
|
1063
1074
|
* all, and it is the only channel that sees a door nobody has ever walked through.
|
|
1064
1075
|
*
|
|
1065
1076
|
* @param {{root: string, config: Record<string, any>, options: CheckOptions}} a
|
|
1066
|
-
* @returns {Promise<Journey[]>}
|
|
1077
|
+
* @returns {Promise<{journeys: Journey[], gaps: CoverageGap[]}>}
|
|
1067
1078
|
*/
|
|
1068
1079
|
async function gatherJourneys({ root, config, options }) {
|
|
1069
1080
|
/** @type {Journey[]} */
|
|
1070
1081
|
const journeys = [];
|
|
1082
|
+
/** @type {CoverageGap[]} */
|
|
1083
|
+
const gaps = [];
|
|
1071
1084
|
|
|
1072
1085
|
const named = options.journeys && options.journeys !== 'code' && options.journeys !== 'config' ? options.journeys : null;
|
|
1073
1086
|
if (named) journeys.push(...(await readJourneyFile(path.resolve(root, named))));
|
|
@@ -1082,21 +1095,44 @@ async function gatherJourneys({ root, config, options }) {
|
|
|
1082
1095
|
let detection;
|
|
1083
1096
|
try {
|
|
1084
1097
|
detection = await adapter.detect(project);
|
|
1085
|
-
} catch {
|
|
1098
|
+
} catch (e) {
|
|
1099
|
+
// BOTH of these used to be swallowed without a word, and that is the same shape of
|
|
1100
|
+
// failure as the source reader skipping a 3.5MB bundle: a whole channel drops out of
|
|
1101
|
+
// the run, nothing is walked, and the verdict says "nothing that worked has changed".
|
|
1102
|
+
// An adapter that FALLS OVER is a hole. An adapter that says "this is not my kind of
|
|
1103
|
+
// project" is not, which is why only the throw is recorded here.
|
|
1104
|
+
gaps.push({
|
|
1105
|
+
what: `Nothing was checked through the "${adapter.name}" adapter, because it could not even work out whether it applies to this project.`,
|
|
1106
|
+
why: messageOf(e),
|
|
1107
|
+
unlockedBy: `Run \`staysfixed doctor\` to see what the ${adapter.name} adapter needs here. Until then, anything only it can see is not being watched.`,
|
|
1108
|
+
});
|
|
1086
1109
|
continue;
|
|
1087
1110
|
}
|
|
1088
1111
|
if (!detection.applies) continue;
|
|
1089
1112
|
if (adapter !== sourceAdapter && named) continue;
|
|
1090
1113
|
try {
|
|
1091
1114
|
journeys.push(...(await adapter.journeys(project)));
|
|
1092
|
-
} catch {
|
|
1093
|
-
|
|
1094
|
-
|
|
1115
|
+
} catch (e) {
|
|
1116
|
+
gaps.push({
|
|
1117
|
+
what: `The "${adapter.name}" adapter applies to this project and could not say what it would walk, so it walked nothing.`,
|
|
1118
|
+
why: messageOf(e),
|
|
1119
|
+
unlockedBy: `Fix what it is complaining about, or name the steps yourself in a journeys file. This is a hole, not a pass.`,
|
|
1120
|
+
});
|
|
1095
1121
|
}
|
|
1096
1122
|
}
|
|
1097
1123
|
|
|
1098
1124
|
const only = options.only ?? [];
|
|
1099
1125
|
const chosen = only.length > 0 ? journeys.filter((j) => only.some((n) => j.name === n || j.name.includes(n))) : journeys;
|
|
1126
|
+
if (only.length > 0) {
|
|
1127
|
+
for (const wanted of only) {
|
|
1128
|
+
if (chosen.some((j) => j.name === wanted || j.name.includes(wanted))) continue;
|
|
1129
|
+
gaps.push({
|
|
1130
|
+
what: `You asked for the journey "${wanted}" and there is no journey by that name, so it was not walked.`,
|
|
1131
|
+
why: 'A name that matches nothing narrows the run to nothing rather than to what you meant.',
|
|
1132
|
+
unlockedBy: `The journeys this project has are: ${journeys.map((j) => j.name).slice(0, 12).join(', ') || 'none'}.`,
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1100
1136
|
|
|
1101
1137
|
// Two journeys with one name would write into one another's records.
|
|
1102
1138
|
/** @type {Journey[]} */
|
|
@@ -1107,7 +1143,7 @@ async function gatherJourneys({ root, config, options }) {
|
|
|
1107
1143
|
seen.add(j.name);
|
|
1108
1144
|
out.push(j);
|
|
1109
1145
|
}
|
|
1110
|
-
return out;
|
|
1146
|
+
return { journeys: out, gaps };
|
|
1111
1147
|
}
|
|
1112
1148
|
|
|
1113
1149
|
/**
|
|
@@ -1188,19 +1224,46 @@ async function readConfig(configFile) {
|
|
|
1188
1224
|
*/
|
|
1189
1225
|
async function fingerprintWorkingTree(root, product) {
|
|
1190
1226
|
const sha = await git(root, ['rev-parse', 'HEAD']);
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1227
|
+
if (!sha) {
|
|
1228
|
+
// REFUSING IS THE ONLY HONEST ANSWER HERE, and the alternative is the worst bug this
|
|
1229
|
+
// tool could have. Without git there is nothing to tell one build from another, so every
|
|
1230
|
+
// run would be fingerprinted identically, the build you just changed would carry the same
|
|
1231
|
+
// id as the build you were happy with, and comparing a build against itself produces zero
|
|
1232
|
+
// differences — a permanent, confident, completely false all-clear.
|
|
1233
|
+
throw new StaysFixedError(
|
|
1234
|
+
'This folder is not a git repository with a commit in it, and Stays Fixed tells one build from another by what git says is in it.',
|
|
1235
|
+
{
|
|
1236
|
+
hint:
|
|
1237
|
+
'Run it inside your project (or `git init && git commit` first). Without git every run would look like the same build, ' +
|
|
1238
|
+
'and a check that compares a build against itself always comes back clean — which would be a lie, so it is refused instead.',
|
|
1239
|
+
},
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
// Streamed into a hash rather than read into a string. `git diff` used to go through a
|
|
1243
|
+
// buffer with a 32MB ceiling, and a diff over the ceiling made the git call FAIL — which
|
|
1244
|
+
// was caught, treated as an empty diff, and the working tree was then declared clean. A
|
|
1245
|
+
// big uncommitted change therefore got the id of the commit it sat on top of; if that
|
|
1246
|
+
// commit was the reference, the check compared the build against itself and reported that
|
|
1247
|
+
// nothing had changed. Nothing about a diff's size may ever decide whether a change exists.
|
|
1248
|
+
const diff = await gitDigest(root, ['diff', 'HEAD']);
|
|
1249
|
+
const untracked = await gitDigest(root, ['ls-files', '--others', '--exclude-standard']);
|
|
1250
|
+
if (!diff.ok || !untracked.ok) {
|
|
1251
|
+
throw new StaysFixedError(
|
|
1252
|
+
`Git could not say what has changed in this working tree, so there is no way to tell this build apart from the last one. ${diff.why ?? untracked.why ?? ''}`.trim(),
|
|
1253
|
+
{ hint: 'Fix that and run again. Guessing "nothing has changed" here would make every later answer worthless.' },
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
const dirty = !diff.empty || !untracked.empty;
|
|
1194
1257
|
const version = await packageVersion(root);
|
|
1195
1258
|
|
|
1196
1259
|
/** @type {BuildFingerprint} */
|
|
1197
1260
|
const build = {
|
|
1198
|
-
id: dirty ? `work-${sha256(`${sha
|
|
1261
|
+
id: dirty ? `work-${sha256(`${sha}\n${diff.digest}\n${untracked.digest}`).slice(0, 12)}` : `git-${sha.slice(0, 12)}`,
|
|
1199
1262
|
product,
|
|
1200
1263
|
platform: `${process.platform}-${process.arch}`,
|
|
1201
1264
|
builtAt: new Date().toISOString(),
|
|
1202
1265
|
};
|
|
1203
|
-
|
|
1266
|
+
build.gitSha = sha;
|
|
1204
1267
|
if (version) build.version = dirty ? `${version} with uncommitted changes` : version;
|
|
1205
1268
|
if (dirty) build.dirty = true;
|
|
1206
1269
|
const branch = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
@@ -1276,6 +1339,41 @@ async function exportBuild(root, reference, scratch) {
|
|
|
1276
1339
|
// Small things
|
|
1277
1340
|
// ---------------------------------------------------------------------------
|
|
1278
1341
|
|
|
1342
|
+
/**
|
|
1343
|
+
* Run a git command and hash its output as it arrives, without ever holding it in memory.
|
|
1344
|
+
*
|
|
1345
|
+
* This exists because of a specific failure: reading `git diff` into a string through a
|
|
1346
|
+
* buffer with a ceiling turns "your change is enormous" into "the command failed", and the
|
|
1347
|
+
* caller then has to guess. A hash costs nothing, has no ceiling, and answers the only two
|
|
1348
|
+
* questions the fingerprint asks — was there anything, and was it the same thing as last time.
|
|
1349
|
+
*
|
|
1350
|
+
* @param {string} cwd
|
|
1351
|
+
* @param {string[]} args
|
|
1352
|
+
* @returns {Promise<{ok: boolean, empty: boolean, digest: string, why?: string}>}
|
|
1353
|
+
*/
|
|
1354
|
+
function gitDigest(cwd, args) {
|
|
1355
|
+
return new Promise((resolve) => {
|
|
1356
|
+
const hash = createHash('sha256');
|
|
1357
|
+
let bytes = 0;
|
|
1358
|
+
/** @type {string[]} */
|
|
1359
|
+
const complaints = [];
|
|
1360
|
+
const child = spawn('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
1361
|
+
child.stdout.on('data', (chunk) => {
|
|
1362
|
+
bytes += chunk.length;
|
|
1363
|
+
hash.update(chunk);
|
|
1364
|
+
});
|
|
1365
|
+
child.stderr.on('data', (chunk) => complaints.push(String(chunk)));
|
|
1366
|
+
child.on('error', (e) => resolve({ ok: false, empty: true, digest: '', why: messageOf(e) }));
|
|
1367
|
+
child.on('close', (code) => {
|
|
1368
|
+
if (code !== 0) {
|
|
1369
|
+
resolve({ ok: false, empty: true, digest: '', why: complaints.join(' ').trim() || `git exited with ${code}` });
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
resolve({ ok: true, empty: bytes === 0, digest: hash.digest('hex') });
|
|
1373
|
+
});
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1279
1377
|
/**
|
|
1280
1378
|
* @param {string} cwd
|
|
1281
1379
|
* @param {string[]} args
|
package/src/v2/cli.js
CHANGED
|
@@ -344,9 +344,15 @@ async function runSelfCheck(ctx, asJson) {
|
|
|
344
344
|
const { selfcheck } = await import('./selfcheck.js');
|
|
345
345
|
const result = await selfcheck({ only: ctx.list('only') });
|
|
346
346
|
|
|
347
|
+
// Three outcomes, not two. "Every break was caught", "a break got through", and "one case
|
|
348
|
+
// behaved on the second run and not the first, so nobody knows" are different facts, and
|
|
349
|
+
// the third one has to be able to say so instead of being rounded to either neighbour.
|
|
350
|
+
const untellable = result.cases.filter((one) => one.verdict === 'could not tell');
|
|
351
|
+
const reallyWrong = result.cases.filter((one) => !one.caught && one.verdict !== 'could not tell');
|
|
352
|
+
|
|
347
353
|
if (asJson) {
|
|
348
354
|
process.stdout.write(JSON.stringify(result) + '\n');
|
|
349
|
-
return result.passed ? EXIT.ok : EXIT.failed;
|
|
355
|
+
return result.passed ? EXIT.ok : reallyWrong.length > 0 ? EXIT.failed : EXIT.error;
|
|
350
356
|
}
|
|
351
357
|
|
|
352
358
|
heading('Stays Fixed — checking that it can still catch things');
|
|
@@ -379,8 +385,15 @@ async function runSelfCheck(ctx, asJson) {
|
|
|
379
385
|
ok(`All ${result.cases.length} of them behaved: every break caught, every clean pair silent.`);
|
|
380
386
|
return EXIT.ok;
|
|
381
387
|
}
|
|
382
|
-
|
|
383
|
-
|
|
388
|
+
if (reallyWrong.length === 0) {
|
|
389
|
+
fail(
|
|
390
|
+
`${untellable.length} of ${result.cases.length} could not be told either way: ${untellable.length === 1 ? 'it' : 'they'} behaved on the second run and not on the first. ` +
|
|
391
|
+
'That is not a pass and not a failure — it is no answer. Run it again on a machine that is not busy.',
|
|
392
|
+
);
|
|
393
|
+
return EXIT.error;
|
|
394
|
+
}
|
|
395
|
+
fail(`It got ${reallyWrong.length} of ${result.cases.length} wrong, twice in a row each. Until that is fixed, a clean check means nothing.`);
|
|
396
|
+
if (untellable.length > 0) fail(`${untellable.length} more could not be told either way.`);
|
|
384
397
|
return EXIT.failed;
|
|
385
398
|
}
|
|
386
399
|
|
package/src/v2/coverage.js
CHANGED
|
@@ -729,8 +729,16 @@ export async function ledger(store, product, opts = {}) {
|
|
|
729
729
|
caveats.push('The doors were handed in by the code reader as this ledger was drawn up, so it knows about doors added since the last run.');
|
|
730
730
|
} else if (opts.root) {
|
|
731
731
|
const reading = await readContract({ root: opts.root });
|
|
732
|
-
|
|
732
|
+
const fileRoutes = await readFileRoutes(opts.root);
|
|
733
|
+
reading.doors.push(...fileRoutes.doors);
|
|
733
734
|
reading.doors.push(...(await readPackageCommands(opts.root)));
|
|
735
|
+
for (const problem of fileRoutes.problems) {
|
|
736
|
+
holes.push({
|
|
737
|
+
what: problem,
|
|
738
|
+
why: 'Doors nobody can see are not counted here, so this ledger is smaller than the product is.',
|
|
739
|
+
unlockedBy: 'Make that folder readable by whoever runs the check.',
|
|
740
|
+
});
|
|
741
|
+
}
|
|
734
742
|
doors = reading.doors.map(doorFact);
|
|
735
743
|
caveats.push(`The code was read as this ledger was drawn up: ${reading.report.filesRead} files, ${reading.doors.length} doors, and nothing was run.`);
|
|
736
744
|
} else {
|
package/src/v2/detect.js
CHANGED
|
@@ -805,7 +805,7 @@ async function readTheSource(root) {
|
|
|
805
805
|
const reading = await readContract({ root });
|
|
806
806
|
const fileRoutes = await readFileRoutes(root);
|
|
807
807
|
const commands = await readPackageCommands(root);
|
|
808
|
-
const doors = [...reading.doors, ...fileRoutes, ...commands];
|
|
808
|
+
const doors = [...reading.doors, ...fileRoutes.doors, ...commands];
|
|
809
809
|
/** @type {Record<string, number>} */
|
|
810
810
|
const counts = {};
|
|
811
811
|
for (const door of doors) counts[door.kind] = (counts[door.kind] ?? 0) + 1;
|
package/src/v2/doctor.js
CHANGED
|
@@ -32,7 +32,7 @@ import { existsSync, accessSync, readFileSync, readdirSync, constants as fsConst
|
|
|
32
32
|
import { execFile } from 'node:child_process';
|
|
33
33
|
import { promisify } from 'node:util';
|
|
34
34
|
|
|
35
|
-
import { findConfigFile } from '../core/paths.js';
|
|
35
|
+
import { findConfigFile, rootForConfig } from '../core/paths.js';
|
|
36
36
|
import { platformTag } from '../drive/find.js';
|
|
37
37
|
import { isRepo } from '../core/git.js';
|
|
38
38
|
import { surveyBrowsers, INSTALL_COMMAND, PORT_NEVER_USE } from './browsers.js';
|
|
@@ -174,7 +174,12 @@ export async function capabilities(opts = {}) {
|
|
|
174
174
|
const cwd = opts.cwd ?? process.cwd();
|
|
175
175
|
const offline = opts.offline === true || process.env.STAYSFIXED_OFFLINE !== undefined;
|
|
176
176
|
const configFile = opts.configFile ? path.resolve(cwd, opts.configFile) : findConfigFile(cwd);
|
|
177
|
-
|
|
177
|
+
// rootForConfig, never path.dirname: a config kept at `.staysfixed/config.json` — one of
|
|
178
|
+
// the six names this tool looks for — would otherwise make the project root the
|
|
179
|
+
// `.staysfixed` folder itself, and every adapter would be asked about an empty directory.
|
|
180
|
+
// Measured on Terminal Deck: doctor reported "a built APK is still missing" with the APK
|
|
181
|
+
// sitting two folders up, and told the agent to go and build one that was already there.
|
|
182
|
+
const root = configFile ? rootForConfig(configFile) : cwd;
|
|
178
183
|
|
|
179
184
|
// The browser survey comes first because three different answers below depend
|
|
180
185
|
// on it, and asking this machine the same question three times would be both
|
|
@@ -258,6 +263,8 @@ const PERMANENT_LIMITS = [
|
|
|
258
263
|
'It checks the journeys it has. It cannot enumerate every possible state, and the coverage ledger names the doors it has never opened rather than pretending they are covered.',
|
|
259
264
|
'Two builds of a real phone in your hand cannot be run side by side. Real devices fall back to comparing against the stored record, and say so.',
|
|
260
265
|
'If the old build can no longer be compiled, comparison falls back to the stored record from the last time it ran. That is genuinely weaker and announces itself on every run.',
|
|
266
|
+
'It will not tell you your product got slower. How long something took is recorded and printed, and never compared: a stopwatch on a shared machine measures how busy the machine is at least as much as it measures the product, so comparing it invents a slowdown every time the machine is busy. A build that HANGS is still caught, because it is stopped for taking too long and how it finished is compared exactly.',
|
|
267
|
+
'A change buried in the middle of an output larger than 64KB can be missed. The two ends are kept and compared along with the exact number of bytes discarded, so a middle that grew or shrank shows up; one that changed without changing length does not. The whole text is written to the evidence folder and the run says it only compared the ends.',
|
|
261
268
|
];
|
|
262
269
|
|
|
263
270
|
/**
|
|
@@ -658,7 +665,12 @@ function findDesktopApp(cwd) {
|
|
|
658
665
|
}
|
|
659
666
|
}
|
|
660
667
|
|
|
661
|
-
|
|
668
|
+
// rootForConfig, never path.dirname: a config kept at `.staysfixed/config.json` — one of
|
|
669
|
+
// the six names this tool looks for — would otherwise make the project root the
|
|
670
|
+
// `.staysfixed` folder itself, and every adapter would be asked about an empty directory.
|
|
671
|
+
// Measured on Terminal Deck: doctor reported "a built APK is still missing" with the APK
|
|
672
|
+
// sitting two folders up, and told the agent to go and build one that was already there.
|
|
673
|
+
const root = configFile ? rootForConfig(configFile) : cwd;
|
|
662
674
|
const ext = process.platform === 'darwin' ? '.app' : process.platform === 'win32' ? '.exe' : '.AppImage';
|
|
663
675
|
for (const folder of ['dist', 'out', 'release', 'build']) {
|
|
664
676
|
const dir = path.join(root, folder);
|
|
@@ -436,7 +436,9 @@ export function journeysFromDoors(doors, options = {}) {
|
|
|
436
436
|
export async function journeysFromCode(opts) {
|
|
437
437
|
const started = Date.now();
|
|
438
438
|
const reading = await readContract({ root: opts.root, folders: opts.folders });
|
|
439
|
-
|
|
439
|
+
const fileRoutes = await readFileRoutes(opts.root);
|
|
440
|
+
reading.doors.push(...fileRoutes.doors);
|
|
441
|
+
reading.report.problems.push(...fileRoutes.problems);
|
|
440
442
|
reading.doors.push(...(await readPackageCommands(opts.root)));
|
|
441
443
|
|
|
442
444
|
const surface =
|