staysfixed 0.6.2 → 0.7.1
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 +207 -0
- package/README.md +104 -43
- package/docs/design-v2.md +275 -0
- package/docs/getting-started.md +295 -0
- package/docs/guards.md +226 -0
- package/docs/how-it-stays-stable.md +315 -0
- package/docs/how-v2-works.md +403 -0
- package/docs/mcp.md +286 -0
- package/docs/running-it-in-ci.md +306 -0
- package/docs/watching.md +190 -0
- package/package.json +3 -3
- package/src/cli/index.js +34 -7
- package/src/core/config.js +12 -2
- package/src/v2/adapters/isolate.js +89 -0
- package/src/v2/cause.js +151 -12
- package/src/v2/check.js +345 -3
- package/src/v2/cli.js +71 -12
- package/src/v2/cluster.js +20 -3
- package/src/v2/coverage.js +40 -5
- package/src/v2/detect.js +1413 -20
- package/src/v2/doctor.js +191 -35
- package/src/v2/init.js +480 -57
- package/src/v2/mcp/tools.js +124 -11
- package/src/v2/normalise.js +54 -7
- package/src/v2/observation.js +56 -10
- package/src/v2/rank.js +212 -43
- package/src/v2/run.js +216 -31
- package/src/v2/selfcheck.js +312 -7
- package/src/v2/store.js +269 -45
- package/src/v2/watch/index.js +96 -17
- package/src/v2/watch/window.js +138 -20
package/src/v2/check.js
CHANGED
|
@@ -32,6 +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
36
|
import { findConfigFile, rootForConfig } from '../core/paths.js';
|
|
36
37
|
import { sha256 } from '../core/hash.js';
|
|
37
38
|
|
|
@@ -43,6 +44,13 @@ import { runCheck, makeCheckEvents } from './run.js';
|
|
|
43
44
|
import { proveCause } from './cause.js';
|
|
44
45
|
import { whatChanged } from './rank.js';
|
|
45
46
|
|
|
47
|
+
import { attachWatcher, watchOptionsFrom } from './watch/index.js';
|
|
48
|
+
import { guardTheScreen, describeGuard } from './watch/focus.js';
|
|
49
|
+
import {
|
|
50
|
+
isOffScreen, moveWindowByPid, offScreen, windowBoundsByPid, withoutTakingTheScreen,
|
|
51
|
+
} from './watch/window.js';
|
|
52
|
+
import { onAppStarted, stillOpen } from './adapters/isolate.js';
|
|
53
|
+
|
|
46
54
|
import { processAdapter } from './adapters/process.js';
|
|
47
55
|
import { sourceAdapter } from './adapters/source.js';
|
|
48
56
|
import { httpAdapter } from './adapters/http.js';
|
|
@@ -65,6 +73,9 @@ const exec = promisify(execFile);
|
|
|
65
73
|
/** @typedef {import('./run.js').LiveBuild} LiveBuild */
|
|
66
74
|
/** @typedef {import('./run.js').WalkRequest} WalkRequest */
|
|
67
75
|
/** @typedef {import('./run.js').CheckEvents} CheckEvents */
|
|
76
|
+
/** @typedef {import('./watch/index.js').PanelOptions} PanelOptions */
|
|
77
|
+
/** @typedef {import('./watch/index.js').WatchFlags} WatchFlags */
|
|
78
|
+
/** @typedef {import('./adapters/isolate.js').OpenedApp} OpenedApp */
|
|
68
79
|
|
|
69
80
|
/**
|
|
70
81
|
* What a check hands back.
|
|
@@ -107,6 +118,8 @@ const exec = promisify(execFile);
|
|
|
107
118
|
* @property {string} [product]
|
|
108
119
|
* @property {CheckEvents} [events]
|
|
109
120
|
* @property {AbortSignal} [signal]
|
|
121
|
+
* @property {WatchFlags} [watch] What the person typed about the live panel. The settings
|
|
122
|
+
* file has its say too, and this is merged over it.
|
|
110
123
|
*/
|
|
111
124
|
|
|
112
125
|
/** The adapters compiled into every copy, in the order the engine trusts them. Reading the code is free, so it is first. */
|
|
@@ -264,8 +277,14 @@ export async function check(options = {}) {
|
|
|
264
277
|
const events = options.events ?? makeCheckEvents();
|
|
265
278
|
/** @type {Project|null} */
|
|
266
279
|
let project = null;
|
|
280
|
+
/** @type {ScreenMinder|null} */
|
|
281
|
+
let screen = null;
|
|
267
282
|
try {
|
|
268
283
|
project = await openProject(options);
|
|
284
|
+
// Before a single thing is opened. Everything this run puts on somebody's screen —
|
|
285
|
+
// the live panel, the desktop app under check — goes through here, and so does the
|
|
286
|
+
// promise that none of it takes the screen off the person using the machine.
|
|
287
|
+
screen = await mindTheScreen(project, events);
|
|
269
288
|
const verdict = await runCheck({
|
|
270
289
|
store: project.store,
|
|
271
290
|
product: project.product,
|
|
@@ -283,6 +302,11 @@ export async function check(options = {}) {
|
|
|
283
302
|
events,
|
|
284
303
|
signal: options.signal,
|
|
285
304
|
});
|
|
305
|
+
// The screen is given back before the answer is written, so anything the guard had
|
|
306
|
+
// to do lands in the sentence a person reads rather than in a log line after it.
|
|
307
|
+
const minded = screen ? await screen.handBack() : null;
|
|
308
|
+
if (minded) verdict.summary = `${verdict.summary} ${minded}`;
|
|
309
|
+
|
|
286
310
|
// The real ledger, door by door, before anything says how much was covered. The loop
|
|
287
311
|
// only knows how many doors it read out of the source and that no journey named one;
|
|
288
312
|
// this reads what every capture of this build actually touched and works out which
|
|
@@ -296,6 +320,17 @@ export async function check(options = {}) {
|
|
|
296
320
|
// confirmation is what lets a caller tell "it went there and found nothing" from
|
|
297
321
|
// "it checked something else and found nothing", and those are not the same answer.
|
|
298
322
|
if (project.target) outcome.target = project.target;
|
|
323
|
+
|
|
324
|
+
// The live window was told the engine's verdict the moment the loop finished — before
|
|
325
|
+
// the gates were applied to it, before the waived findings were taken out, and before
|
|
326
|
+
// the coverage sentence went on the end. Left there, a window would show a greener,
|
|
327
|
+
// shorter answer than the terminal beside it, and the two would disagree about the same
|
|
328
|
+
// run. So it is told again, with the settled one, and only then put away.
|
|
329
|
+
events.emit({ type: 'check:done', at: events.elapsed(), message: outcome.summary, verdict: outcome });
|
|
330
|
+
if (screen) {
|
|
331
|
+
await screen.finish();
|
|
332
|
+
screen = null;
|
|
333
|
+
}
|
|
299
334
|
return outcome;
|
|
300
335
|
} catch (e) {
|
|
301
336
|
const outcome = blocked(options, e);
|
|
@@ -305,9 +340,28 @@ export async function check(options = {}) {
|
|
|
305
340
|
// a folder of its own behind as its parting gesture.
|
|
306
341
|
const store = openStore({ root: projectRootFor(options) });
|
|
307
342
|
if (storeExists(store)) await settle(outcome, store, outcome.product);
|
|
343
|
+
// And the window hears it too. Without this a check that was blocked leaves a window
|
|
344
|
+
// sitting there saying "running" for the rest of the day, which is the one thing worse
|
|
345
|
+
// than no window: it looks like a check that is still going rather than one that never
|
|
346
|
+
// got anywhere.
|
|
347
|
+
events.emit({ type: 'check:done', at: events.elapsed(), message: outcome.summary, verdict: outcome });
|
|
308
348
|
return outcome;
|
|
309
349
|
} finally {
|
|
350
|
+
// A check that threw is exactly when a scratch app is left standing on somebody's
|
|
351
|
+
// screen and a guard is left polling for it, so this runs whatever happened.
|
|
352
|
+
if (screen) await screen.finish().catch(() => {});
|
|
310
353
|
if (project) await project.close();
|
|
354
|
+
// Everything this run opened has to be gone. `project.close` tears the adapters down
|
|
355
|
+
// and each of them releases its own isolations; if anything is still on the books
|
|
356
|
+
// after that, the tool has left a copy of somebody's app running, and that is worth
|
|
357
|
+
// saying out loud rather than discovering as a second app on the screen.
|
|
358
|
+
const left = stillOpen();
|
|
359
|
+
if (left > 0) {
|
|
360
|
+
warn(
|
|
361
|
+
`${left} ${left === 1 ? 'copy' : 'copies'} of an app this check opened could not be accounted for at the end. ` +
|
|
362
|
+
'Look for a stray window before running it again: two copies of one app fight over the same settings and the same identity.',
|
|
363
|
+
);
|
|
364
|
+
}
|
|
311
365
|
}
|
|
312
366
|
}
|
|
313
367
|
|
|
@@ -667,7 +721,13 @@ export async function explain(options = {}) {
|
|
|
667
721
|
}
|
|
668
722
|
if (differences.length > 40) out.push(` and ${differences.length - 40} more.`);
|
|
669
723
|
if (f.nearFiles?.length) out.push('', `Nearest code: ${f.nearFiles.slice(0, 6).join(', ')}.`);
|
|
670
|
-
|
|
724
|
+
// No full stop of our own: the reason is already a whole sentence and adding one gave the
|
|
725
|
+
// agent "...costs a real person real money.." on the reply it reads when it is trying to
|
|
726
|
+
// understand something it is not allowed to waive.
|
|
727
|
+
if (f.unwaivable === true) {
|
|
728
|
+
const why = String(f.unwaivableWhy ?? 'a person has to look at it').trim();
|
|
729
|
+
out.push('', `This cannot be recorded as intended by anyone: ${/[.!?]$/.test(why) ? why : `${why}.`}`);
|
|
730
|
+
}
|
|
671
731
|
if (f.waivedBecause) out.push('', `Already recorded as intended: ${f.waivedBecause}`);
|
|
672
732
|
|
|
673
733
|
const pictures = differences.map((d) => d.evidence).filter((/** @type {string|undefined} */ e) => typeof e === 'string' && /\.png$/i.test(e));
|
|
@@ -683,6 +743,259 @@ function short(value) {
|
|
|
683
743
|
return text.length > 200 ? `${text.slice(0, 197)}...` : text;
|
|
684
744
|
}
|
|
685
745
|
|
|
746
|
+
// ---------------------------------------------------------------------------
|
|
747
|
+
// The screen, while a check is running
|
|
748
|
+
// ---------------------------------------------------------------------------
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* The kinds of product that put something on somebody's screen.
|
|
752
|
+
*
|
|
753
|
+
* Everything else — a command, a library, an HTTP route, source read off disk — opens
|
|
754
|
+
* nothing at all, and a run made only of those must not so much as ask macOS which
|
|
755
|
+
* application is in front. Asking is not free: the first time anything on this machine
|
|
756
|
+
* asks, the person gets a permission dialog, and getting one of those out of a check that
|
|
757
|
+
* was never going to show them anything is its own small betrayal.
|
|
758
|
+
*
|
|
759
|
+
* Windows is deliberately NOT on this list even though it drives a real desktop. That probe
|
|
760
|
+
* runs on another machine over SSH, so whatever it puts in front of anybody is in front of
|
|
761
|
+
* somebody else's screen, and nothing here can reach it.
|
|
762
|
+
*
|
|
763
|
+
* @type {Set<string>}
|
|
764
|
+
*/
|
|
765
|
+
const PUTS_SOMETHING_ON_THE_SCREEN = new Set(['electron', 'ios', 'android']);
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* How long to keep looking for the window of an app that has just been started.
|
|
769
|
+
*
|
|
770
|
+
* A desktop app takes a second or two to draw its first window, and on a cold machine it
|
|
771
|
+
* takes longer. There is no event to wait for — the window belongs to the window server,
|
|
772
|
+
* not to us — so this is looked for, slowly, and given up on without a word.
|
|
773
|
+
*/
|
|
774
|
+
const WAIT_FOR_A_WINDOW_MS = 20_000;
|
|
775
|
+
|
|
776
|
+
/** How often to look for it. Slow on purpose: nothing here is racing. */
|
|
777
|
+
const LOOK_FOR_A_WINDOW_MS = 400;
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* Everything this run put on the screen, and the promise to give the screen back.
|
|
781
|
+
*
|
|
782
|
+
* Two steps rather than one, and the gap between them is the point: the screen is handed
|
|
783
|
+
* back the moment the walking stops, and the window is left up long enough to be told the
|
|
784
|
+
* settled answer.
|
|
785
|
+
*
|
|
786
|
+
* @typedef {object} ScreenMinder
|
|
787
|
+
* @property {() => Promise<string|null>} handBack Stop guarding, and hand back the one
|
|
788
|
+
* sentence worth saying — or null when there is nothing worth saying, which is the normal
|
|
789
|
+
* case and the whole rule: a person who was not interrupted is not told about the
|
|
790
|
+
* machinery that did not interrupt them.
|
|
791
|
+
* @property {() => Promise<void>} finish Put the window away. Safe at any point, safe
|
|
792
|
+
* twice, and safe without `handBack` ever having been called.
|
|
793
|
+
*/
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* Look after the screen for the length of one check.
|
|
797
|
+
*
|
|
798
|
+
* Three jobs, and they are one job seen from three sides.
|
|
799
|
+
*
|
|
800
|
+
* THE GUARD. `watch/focus.js` watches who is in front and puts the person back the moment
|
|
801
|
+
* something of ours pushes in front of them. It is the answer to the complaint that
|
|
802
|
+
* started this: an app the tool opens may come up ONCE, because watching it work is most
|
|
803
|
+
* of how you come to trust it, and after the person has chosen something else it never
|
|
804
|
+
* comes forward again. Nothing else in this tool can do that job, because nothing else
|
|
805
|
+
* knows which applications on this machine belong to the run.
|
|
806
|
+
*
|
|
807
|
+
* THE PANEL. `--watch` opens the live view beside the app, and the app is pinned to its
|
|
808
|
+
* edge so the two of them read as one window.
|
|
809
|
+
*
|
|
810
|
+
* OUT OF SIGHT. With no panel, nobody asked to watch anything, so a desktop app this run
|
|
811
|
+
* starts is moved off every screen once its window appears. It still runs, still answers
|
|
812
|
+
* the debugging protocol and still photographs — the picture comes from the compositor,
|
|
813
|
+
* which does not care where the window is — it simply never appears in front of anybody.
|
|
814
|
+
*
|
|
815
|
+
* Every part of this is best effort and every failure is swallowed. A machine with no
|
|
816
|
+
* window server, no accessibility permission or no browser still runs the check; it just
|
|
817
|
+
* does not get looked after, which is a disappointment and never a failed check.
|
|
818
|
+
*
|
|
819
|
+
* @param {Project} project
|
|
820
|
+
* @param {CheckEvents} events
|
|
821
|
+
* @returns {Promise<ScreenMinder|null>}
|
|
822
|
+
*/
|
|
823
|
+
async function mindTheScreen(project, events) {
|
|
824
|
+
const watch = project.watch;
|
|
825
|
+
const wantsPanel = watch.enabled === true;
|
|
826
|
+
const couldShow = project.journeys.some((j) => PUTS_SOMETHING_ON_THE_SCREEN.has(String(j.surface)));
|
|
827
|
+
// Nothing will appear and nobody asked for a window: there is no screen to look after.
|
|
828
|
+
if (!wantsPanel && !couldShow) return null;
|
|
829
|
+
|
|
830
|
+
const guard = guardTheScreen();
|
|
831
|
+
// Said under --verbose rather than always, because a person who was not interrupted
|
|
832
|
+
// should not be told about the machinery that did not interrupt them. It is here at all
|
|
833
|
+
// so that "the guard is running" is something anybody can see rather than take on trust.
|
|
834
|
+
detail(
|
|
835
|
+
'The screen guard is watching. Anything this check opens may come to the front once; from the moment you pick something else, it stays behind you.',
|
|
836
|
+
);
|
|
837
|
+
|
|
838
|
+
const watcher = wantsPanel
|
|
839
|
+
? await attachWatcher(events, {
|
|
840
|
+
product: project.product,
|
|
841
|
+
project: project.root,
|
|
842
|
+
journeys: project.journeys,
|
|
843
|
+
watch,
|
|
844
|
+
dir: project.store.dir,
|
|
845
|
+
// The panel's own window is ours, so the guard has to know about it — with two
|
|
846
|
+
// exceptions, and both of them are cases where pushing that window back would be
|
|
847
|
+
// the tool overruling somebody.
|
|
848
|
+
//
|
|
849
|
+
// A BORROWED browser is the person's own. There was no Chrome for Testing here, so
|
|
850
|
+
// the panel opened in the browser they actually use; claiming it would have the
|
|
851
|
+
// guard shoving them out of their own tabs every time they clicked into them.
|
|
852
|
+
//
|
|
853
|
+
// AND --watch-front is somebody asking, in so many words, for this window in front.
|
|
854
|
+
// Claiming it would have the guard undoing the flag a second after it was obeyed.
|
|
855
|
+
onOpen: (browser) => {
|
|
856
|
+
if (browser.borrowed || watch.foreground === true) return;
|
|
857
|
+
guard.claim(browser.name);
|
|
858
|
+
},
|
|
859
|
+
})
|
|
860
|
+
: null;
|
|
861
|
+
|
|
862
|
+
/** @type {Promise<void>[]} */
|
|
863
|
+
const placing = [];
|
|
864
|
+
// Read by the wait below, so a check that ends while an app is still deciding whether
|
|
865
|
+
// to draw a window does not sit there for another twenty seconds over the arrangement
|
|
866
|
+
// of a window nobody is going to see.
|
|
867
|
+
let stopped = false;
|
|
868
|
+
const stopListening = onAppStarted((app) => {
|
|
869
|
+
// Claimed the instant the process exists, before it has drawn anything. A moment
|
|
870
|
+
// later and its first appearance is read as the person choosing it.
|
|
871
|
+
guard.claim(app.name);
|
|
872
|
+
events.emit({
|
|
873
|
+
type: 'note',
|
|
874
|
+
at: events.elapsed(),
|
|
875
|
+
message: `${app.label} is open as "${app.name}". It is a scratch copy, on its own settings, and it is not your own install.`,
|
|
876
|
+
});
|
|
877
|
+
placing.push(place(app, watcher, events, () => stopped));
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
/** @type {Promise<string|null>|null} */
|
|
881
|
+
let handingBack = null;
|
|
882
|
+
/** @type {Promise<void>|null} */
|
|
883
|
+
let finishing = null;
|
|
884
|
+
|
|
885
|
+
const handBack = () => {
|
|
886
|
+
handingBack ??= (async () => {
|
|
887
|
+
stopped = true;
|
|
888
|
+
stopListening();
|
|
889
|
+
await guard.release();
|
|
890
|
+
const line = describeGuard(guard.report());
|
|
891
|
+
if (line) events.emit({ type: 'note', at: events.elapsed(), message: line });
|
|
892
|
+
await Promise.allSettled(placing);
|
|
893
|
+
return line;
|
|
894
|
+
})();
|
|
895
|
+
return handingBack;
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
return {
|
|
899
|
+
handBack,
|
|
900
|
+
finish: () => {
|
|
901
|
+
finishing ??= (async () => {
|
|
902
|
+
await handBack();
|
|
903
|
+
if (watcher) {
|
|
904
|
+
try {
|
|
905
|
+
await watcher.stop();
|
|
906
|
+
} catch {
|
|
907
|
+
// A window that will not close is never a reason to change a verdict.
|
|
908
|
+
}
|
|
909
|
+
// The claim this whole arrangement makes — that the window never held the check
|
|
910
|
+
// up — is worth a number rather than trust. Under --verbose, because a person who
|
|
911
|
+
// is not asking how it went does not need to be told.
|
|
912
|
+
const health = watcher.open() ? watcher.health() : null;
|
|
913
|
+
// Silence here is the worst answer: somebody asked to watch this and got
|
|
914
|
+
// nothing, with no idea whether the window failed or they typed it wrong. Every
|
|
915
|
+
// way this can go actually WRONG says so in its own words as it happens, so the
|
|
916
|
+
// only case left for this line is the one nothing else covers: a window called
|
|
917
|
+
// off because it was going to be closed the moment it arrived.
|
|
918
|
+
if (!health && watch.keepOpen === false) {
|
|
919
|
+
warn(
|
|
920
|
+
'You asked to watch this and no window came up. With --no-keep-open, a window still opening when the check finishes is ' +
|
|
921
|
+
'called off, because it would only be closed again a second later. Leave --no-keep-open out and it waits, then comes up ' +
|
|
922
|
+
'with the finished result on it.',
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
if (health) {
|
|
926
|
+
detail(
|
|
927
|
+
`The watch window took ${health.delivered} of ${health.pushed} updates` +
|
|
928
|
+
`${health.dropped > 0 ? `, folded ${health.dropped} away while it was catching up` : ''}` +
|
|
929
|
+
`${health.stalls > 0 ? `, and gave up on ${health.stalls} that ran past their moment` : ''}` +
|
|
930
|
+
'. The check waited on none of them.',
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
})();
|
|
935
|
+
return finishing;
|
|
936
|
+
},
|
|
937
|
+
};
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* Put one just-started desktop app where it belongs.
|
|
942
|
+
*
|
|
943
|
+
* With a panel: beside it, both windows pinned to one edge, one shape. Without: out of
|
|
944
|
+
* sight, because nobody asked to watch anything.
|
|
945
|
+
*
|
|
946
|
+
* @param {OpenedApp} app
|
|
947
|
+
* @param {import('./watch/index.js').Watcher|null} watcher
|
|
948
|
+
* @param {CheckEvents} events
|
|
949
|
+
* @param {() => boolean} stopped
|
|
950
|
+
* @returns {Promise<void>}
|
|
951
|
+
*/
|
|
952
|
+
async function place(app, watcher, events, stopped) {
|
|
953
|
+
if (watcher) {
|
|
954
|
+
// The panel knows how to find the window itself, and it is the thing that has to be
|
|
955
|
+
// moved either way, so the whole arrangement is done on that side.
|
|
956
|
+
await watcher.snapTo({ pid: app.pid, hasWindow: true }).catch(() => {});
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
const where = await waitForItsWindow(app.pid, stopped);
|
|
960
|
+
if (stopped()) return;
|
|
961
|
+
if (!where) return;
|
|
962
|
+
if (isOffScreen(where)) return;
|
|
963
|
+
// Moving a window can pull the application it belongs to in front of everything else,
|
|
964
|
+
// so whoever had the screen gets it straight back.
|
|
965
|
+
const moved = await withoutTakingTheScreen(async () => moveWindowByPid(app.pid, where, offScreen(where)));
|
|
966
|
+
if (moved) {
|
|
967
|
+
events.emit({
|
|
968
|
+
type: 'note',
|
|
969
|
+
at: events.elapsed(),
|
|
970
|
+
message: `Nobody asked to watch this run, so ${app.label} was moved off the screen. It is still running and still being read; it is just not in front of you. Run this with --watch to see it work.`,
|
|
971
|
+
});
|
|
972
|
+
} else {
|
|
973
|
+
detail(`${app.label} could not be moved out of sight, so its window is on the screen. The screen guard will keep it from taking the foreground.`);
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/**
|
|
978
|
+
* Wait for an app to draw its first window, and give up quietly.
|
|
979
|
+
*
|
|
980
|
+
* @param {number} pid
|
|
981
|
+
* @param {() => boolean} stopped
|
|
982
|
+
* @returns {Promise<import('../watch/place.js').Bounds|null>}
|
|
983
|
+
*/
|
|
984
|
+
async function waitForItsWindow(pid, stopped) {
|
|
985
|
+
const deadline = Date.now() + WAIT_FOR_A_WINDOW_MS;
|
|
986
|
+
for (;;) {
|
|
987
|
+
if (stopped()) return null;
|
|
988
|
+
const seen = await windowBoundsByPid(pid);
|
|
989
|
+
if (seen) return seen;
|
|
990
|
+
if (Date.now() > deadline || stopped()) return null;
|
|
991
|
+
await new Promise((resolve) => {
|
|
992
|
+
const timer = setTimeout(resolve, LOOK_FOR_A_WINDOW_MS);
|
|
993
|
+
// Never the reason a finished program stays open.
|
|
994
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
686
999
|
// ---------------------------------------------------------------------------
|
|
687
1000
|
// Opening a project
|
|
688
1001
|
// ---------------------------------------------------------------------------
|
|
@@ -704,6 +1017,9 @@ function short(value) {
|
|
|
704
1017
|
* @property {import('./run.js').Walker} walk
|
|
705
1018
|
* @property {(reference: BuildFingerprint, ctx: {events?: CheckEvents, signal?: AbortSignal}) => Promise<LiveBuild|null>} bootReference
|
|
706
1019
|
* @property {(capture: Capture) => Capture} normalise
|
|
1020
|
+
* @property {PanelOptions} watch What the settings file and the command line, together,
|
|
1021
|
+
* said about the live panel. Settled once here so the command line and the MCP server
|
|
1022
|
+
* cannot disagree about what `--watch` meant.
|
|
707
1023
|
* @property {{surface: string, at: string|null}} [target] Set only when the run was aimed
|
|
708
1024
|
* at one kind of product AND something here can actually drive it.
|
|
709
1025
|
* @property {() => Promise<void>} close
|
|
@@ -807,6 +1123,9 @@ async function openProject(options) {
|
|
|
807
1123
|
walk,
|
|
808
1124
|
bootReference,
|
|
809
1125
|
normalise,
|
|
1126
|
+
// The settings file first, then whatever was typed. `--watch` can only ever turn the
|
|
1127
|
+
// panel ON: somebody who did not type it has not said no to it, they have said nothing.
|
|
1128
|
+
watch: watchOptionsFrom(/** @type {{watch?: import('../types.js').WatchOptions|boolean}} */ (config), options.watch ?? null),
|
|
810
1129
|
close: async () => {
|
|
811
1130
|
for (const done of cleanUps.reverse()) {
|
|
812
1131
|
try {
|
|
@@ -1211,6 +1530,29 @@ async function readConfig(configFile) {
|
|
|
1211
1530
|
// Which build is which
|
|
1212
1531
|
// ---------------------------------------------------------------------------
|
|
1213
1532
|
|
|
1533
|
+
/**
|
|
1534
|
+
* Everything this tool writes about your project, kept out of what your project IS.
|
|
1535
|
+
*
|
|
1536
|
+
* This one line is load-bearing and it was missing, and the bug it caused reached all the
|
|
1537
|
+
* way to the front door. A build is told from another build by what git says is in the
|
|
1538
|
+
* working tree — the diff, plus the list of files git does not know about. Stays Fixed's own
|
|
1539
|
+
* folder is a file git does not know about, and it gains files every single time the tool
|
|
1540
|
+
* runs. So the untracked list changed on every run, the digest changed with it, and every
|
|
1541
|
+
* run of an UNCHANGED project produced a brand new build id.
|
|
1542
|
+
*
|
|
1543
|
+
* The consequences were all silent. Two runs on identical source were two different builds,
|
|
1544
|
+
* so the second could never find the first one's record. A clean checkout was never clean, so
|
|
1545
|
+
* it never got its commit's id, so `--against HEAD` matched nothing and the stored-record
|
|
1546
|
+
* comparison — the fast path the whole design rests on — could not work at all. Measured on
|
|
1547
|
+
* a scratch product: five runs, one unchanged source file, five different build ids and five
|
|
1548
|
+
* runs reporting NOTHING WAS ACTUALLY COMPARED.
|
|
1549
|
+
*
|
|
1550
|
+
* Excluded rather than gitignored, and that difference matters: gitignoring it would fix the
|
|
1551
|
+
* fingerprint and would also throw away the observation files the design says to keep
|
|
1552
|
+
* forever. What a project's own tooling wrote about a project is never part of the project.
|
|
1553
|
+
*/
|
|
1554
|
+
const NOT_THE_TOOLS_OWN_FOLDER = ':(exclude).staysfixed';
|
|
1555
|
+
|
|
1214
1556
|
/**
|
|
1215
1557
|
* The build you have, named by what is actually in it.
|
|
1216
1558
|
*
|
|
@@ -1245,8 +1587,8 @@ async function fingerprintWorkingTree(root, product) {
|
|
|
1245
1587
|
// big uncommitted change therefore got the id of the commit it sat on top of; if that
|
|
1246
1588
|
// commit was the reference, the check compared the build against itself and reported that
|
|
1247
1589
|
// 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']);
|
|
1590
|
+
const diff = await gitDigest(root, ['diff', 'HEAD', '--', NOT_THE_TOOLS_OWN_FOLDER]);
|
|
1591
|
+
const untracked = await gitDigest(root, ['ls-files', '--others', '--exclude-standard', '--', NOT_THE_TOOLS_OWN_FOLDER]);
|
|
1250
1592
|
if (!diff.ok || !untracked.ok) {
|
|
1251
1593
|
throw new StaysFixedError(
|
|
1252
1594
|
`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(),
|
package/src/v2/cli.js
CHANGED
|
@@ -9,9 +9,17 @@
|
|
|
9
9
|
* NOTHING THAT WORKED THIS MORNING MAY BREAK. Somebody installed this yesterday
|
|
10
10
|
* and has `staysfixed check --guards` in a git hook. So the version 1 commands
|
|
11
11
|
* are not removed, not renamed and not deprecated with a warning: the same flags
|
|
12
|
-
* they always typed still reach the same code. `--pictures
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* they always typed still reach the same code. `--pictures` and `--guards` are
|
|
13
|
+
* the version 1 check, exactly as before. `check` with neither of them is the
|
|
14
|
+
* difference engine. That is the whole migration.
|
|
15
|
+
*
|
|
16
|
+
* `--watch` is the one flag that moved, and deliberately. It opens the live panel
|
|
17
|
+
* beside whatever is being checked, and that panel is now version 2's — the one
|
|
18
|
+
* built for a difference engine, which draws journeys, wobble, findings and a
|
|
19
|
+
* verdict. Version 1's panel drew approved pictures, which this tool no longer
|
|
20
|
+
* has. `--pictures --watch` and `--guards --watch` still open version 1's panel
|
|
21
|
+
* over version 1's run, so the only person whose command changed meaning is the
|
|
22
|
+
* one who typed `--watch` on its own and got a picture check they did not ask for.
|
|
15
23
|
*
|
|
16
24
|
* This module deliberately holds no engine logic. It parses, it delegates, and
|
|
17
25
|
* it says what came back in plain English — which is the one job that has to
|
|
@@ -23,6 +31,11 @@ import { say, ok, warn, fail, blank, heading, paint, duration, setLogLevel } fro
|
|
|
23
31
|
import { openStore } from './store.js';
|
|
24
32
|
import { SHIP_COMMANDS } from './ship.js';
|
|
25
33
|
import { escalationBlock, escalationsFor, productFor, writeEscalations } from './escalate.js';
|
|
34
|
+
// The one reader of the panel flags, shared with version 1 so `--watch-side` cannot come
|
|
35
|
+
// to mean two different things depending on which check you ran. src/cli/index.js imports
|
|
36
|
+
// this file in turn; that circle is safe because nothing here touches it while either
|
|
37
|
+
// module is still being evaluated.
|
|
38
|
+
import { watchFlags } from '../cli/index.js';
|
|
26
39
|
|
|
27
40
|
/**
|
|
28
41
|
* What comes back from a check. Everything that did not change never appears
|
|
@@ -58,19 +71,37 @@ const V2_OPTIONS = [
|
|
|
58
71
|
['--escalations <file>', 'Write the handful of things a person has to rule on into a file, in plain English, ready to paste into a closing summary.'],
|
|
59
72
|
];
|
|
60
73
|
|
|
61
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* The version 1 flags, kept working word for word — plus `snap`, which never worked
|
|
76
|
+
* anywhere.
|
|
77
|
+
*
|
|
78
|
+
* `--no-snap` is documented at the top of src/cli/check.js and read there as
|
|
79
|
+
* `ctx.flags.snap`, and it was in no command's list of known flags, so typing it got
|
|
80
|
+
* "I do not know the option --no-snap" from both `check` and `walk`. A flag that is
|
|
81
|
+
* read but never declared is worse than one that does not exist: the code that reads
|
|
82
|
+
* it looks finished.
|
|
83
|
+
*/
|
|
62
84
|
const V1_SPEC = {
|
|
63
|
-
booleans: ['guards', 'pictures', 'record', 'report', 'watch', 'watch-front', 'keep-open', 'profile'],
|
|
85
|
+
booleans: ['guards', 'pictures', 'record', 'report', 'watch', 'watch-front', 'keep-open', 'profile', 'snap'],
|
|
64
86
|
strings: ['watch-side', 'watch-width'],
|
|
65
87
|
arrays: ['only'],
|
|
66
88
|
};
|
|
67
89
|
|
|
90
|
+
/** @type {[string, string][]} */
|
|
91
|
+
const WATCH_OPTIONS = [
|
|
92
|
+
['--watch', 'Open a window beside what is being checked and watch it happen, live. Without this, a desktop app under check is moved off the screen rather than appearing in front of you.'],
|
|
93
|
+
['--watch-side <side>', 'Which side of the app the window sits on: left or right. Default right.'],
|
|
94
|
+
['--watch-width <px>', 'How wide that window is. Default 480.'],
|
|
95
|
+
['--watch-front', 'Let the window come to the front when it opens. Off by default, on purpose.'],
|
|
96
|
+
['--no-keep-open', 'Close the window when the check finishes instead of leaving the result up.'],
|
|
97
|
+
['--no-snap', 'Leave both windows where they are instead of putting them side by side.'],
|
|
98
|
+
];
|
|
99
|
+
|
|
68
100
|
/** @type {[string, string][]} */
|
|
69
101
|
const V1_OPTIONS = [
|
|
70
102
|
['--pictures', 'The version 1 picture check, unchanged.'],
|
|
71
103
|
['--guards', 'The version 1 guards, unchanged.'],
|
|
72
104
|
['--only <name>', 'Just this journey, screen or guard. Repeat it for several.'],
|
|
73
|
-
['--watch', 'Open the version 1 panel beside your app and watch it happen.'],
|
|
74
105
|
];
|
|
75
106
|
|
|
76
107
|
/**
|
|
@@ -104,16 +135,17 @@ export const V2_COMMANDS = {
|
|
|
104
135
|
|
|
105
136
|
check: {
|
|
106
137
|
summary: 'Prove nothing that already worked has changed. This is the one you run.',
|
|
107
|
-
usage: 'staysfixed check [--against <ref>] [--paired] [--journeys <source>] [--json]',
|
|
138
|
+
usage: 'staysfixed check [--against <ref>] [--paired] [--journeys <source>] [--watch] [--json]',
|
|
108
139
|
describe:
|
|
109
|
-
'Runs your product through the same steps twice, compares it against the build you\nwere last happy with, subtracts anything the product disagrees with itself about,\nand reports only the differences that are left. Nothing that was already the same\nis mentioned at all — that is the point, and it is what keeps the answer short\nenough for an agent to read every word of it.\n\nSaying what you meant to change, and marking a difference as intended, are not\ndone from here. They need the files you expect to touch, and they are checked\nand counted, so they live where an agent works: the staysfixed_intent and\nstaysfixed_waive tools on the MCP server.\n\nThe version 1 picture check is still here: add --pictures
|
|
110
|
-
options: [...V2_OPTIONS, ...V1_OPTIONS],
|
|
140
|
+
'Runs your product through the same steps twice, compares it against the build you\nwere last happy with, subtracts anything the product disagrees with itself about,\nand reports only the differences that are left. Nothing that was already the same\nis mentioned at all — that is the point, and it is what keeps the answer short\nenough for an agent to read every word of it.\n\nSaying what you meant to change, and marking a difference as intended, are not\ndone from here. They need the files you expect to touch, and they are checked\nand counted, so they live where an agent works: the staysfixed_intent and\nstaysfixed_waive tools on the MCP server.\n\nWith --watch it opens a window beside what is being checked and draws the run as\nit happens. Nothing this tool opens is allowed to keep taking your screen: it may\ncome up once, and from the moment you pick something else it stays behind you.\n\nThe version 1 picture check is still here: add --pictures or --guards and nothing\nabout your old command changes.',
|
|
141
|
+
options: [...V2_OPTIONS, ...WATCH_OPTIONS, ...V1_OPTIONS],
|
|
111
142
|
examples: [
|
|
112
143
|
'staysfixed check',
|
|
113
144
|
'staysfixed check --json',
|
|
114
145
|
'staysfixed check --against v0.13.0',
|
|
115
146
|
'staysfixed check --paired',
|
|
116
147
|
'staysfixed check --surface web --at http://localhost:3000',
|
|
148
|
+
'staysfixed check --watch',
|
|
117
149
|
'staysfixed check --selfcheck',
|
|
118
150
|
'staysfixed check --pictures # exactly what version 1 did',
|
|
119
151
|
],
|
|
@@ -298,7 +330,25 @@ function nameOfBuild(build) {
|
|
|
298
330
|
* @returns {boolean}
|
|
299
331
|
*/
|
|
300
332
|
export function wantsVersionOne(ctx) {
|
|
301
|
-
return ctx.bool('pictures') || ctx.bool('guards') || ctx.bool('
|
|
333
|
+
return ctx.bool('pictures') || ctx.bool('guards') || ctx.bool('record');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* What the person typed about the live panel.
|
|
338
|
+
*
|
|
339
|
+
* The flags themselves are read by version 1's reader, so the two checks cannot drift
|
|
340
|
+
* apart on what `--watch-side left` means. Only `--no-snap` is added here, and only
|
|
341
|
+
* because it is the one panel flag whose whole meaning is "change nothing at all" — it
|
|
342
|
+
* has to be left undefined when it was not typed, so the settings file still decides.
|
|
343
|
+
*
|
|
344
|
+
* @param {import('../cli/index.js').CliContext} ctx
|
|
345
|
+
* @returns {import('./watch/index.js').WatchFlags}
|
|
346
|
+
*/
|
|
347
|
+
function panelFlags(ctx) {
|
|
348
|
+
/** @type {import('./watch/index.js').WatchFlags} */
|
|
349
|
+
const wanted = { ...watchFlags(ctx) };
|
|
350
|
+
if (ctx.flags.snap !== undefined) wanted.snap = ctx.flags.snap === true;
|
|
351
|
+
return wanted;
|
|
302
352
|
}
|
|
303
353
|
|
|
304
354
|
/**
|
|
@@ -312,9 +362,17 @@ export function wantsVersionOne(ctx) {
|
|
|
312
362
|
* caller in three getting a silent undefined.
|
|
313
363
|
*
|
|
314
364
|
* @param {import('../cli/index.js').CliContext} ctx
|
|
315
|
-
* @returns {{cwd: string, configFile: string|undefined, against: string|undefined, paired: boolean, journeys: string|undefined, surface: string|undefined, at: string|undefined, only: string[]}}
|
|
365
|
+
* @returns {{cwd: string, configFile: string|undefined, against: string|undefined, paired: boolean, journeys: string|undefined, surface: string|undefined, at: string|undefined, only: string[], watch: import('./watch/index.js').WatchFlags}}
|
|
316
366
|
*/
|
|
317
367
|
export function checkOptions(ctx) {
|
|
368
|
+
const watch = panelFlags(ctx);
|
|
369
|
+
// A window to look at and output for a script want opposite things, and one stray
|
|
370
|
+
// sentence on standard output is a JSON reply that will not parse. Saying so is better
|
|
371
|
+
// than quietly picking one.
|
|
372
|
+
if (watch.enabled === true && ctx.bool('json')) {
|
|
373
|
+
warn('--watch and --json want opposite things: a window to look at, and output a script can read. Carrying on without the window.');
|
|
374
|
+
watch.enabled = false;
|
|
375
|
+
}
|
|
318
376
|
return {
|
|
319
377
|
cwd: ctx.cwd,
|
|
320
378
|
configFile: ctx.configFile,
|
|
@@ -324,6 +382,7 @@ export function checkOptions(ctx) {
|
|
|
324
382
|
surface: ctx.str('surface'),
|
|
325
383
|
at: ctx.str('at'),
|
|
326
384
|
only: ctx.list('only'),
|
|
385
|
+
watch,
|
|
327
386
|
};
|
|
328
387
|
}
|
|
329
388
|
|
|
@@ -636,7 +695,7 @@ function notCheckedBlock(verdict) {
|
|
|
636
695
|
);
|
|
637
696
|
say(
|
|
638
697
|
paint.grey(
|
|
639
|
-
` A break behind ${unopened === 1 ? 'it' : 'any of them'} is invisible to this tool. Point a journey at ${unopened === 1 ? 'it' : 'them'}
|
|
698
|
+
` A break behind ${unopened === 1 ? 'it' : 'any of them'} is invisible to this tool. Point a journey at ${unopened === 1 ? 'it' : 'them'} — name the steps in a journeys file and pass it with --journeys.`,
|
|
640
699
|
),
|
|
641
700
|
);
|
|
642
701
|
}
|
package/src/v2/cluster.js
CHANGED
|
@@ -199,11 +199,20 @@ function buildFinding(signature, members, rename, sources) {
|
|
|
199
199
|
// Half the differences in a rename are the "vanished" side, so the count of
|
|
200
200
|
// places is the count of pairs, not of rows.
|
|
201
201
|
const count = rename ? Math.max(1, Math.round(members.length / 2)) : members.length;
|
|
202
|
+
// Do all the members really say the same thing, or only the same KIND of thing?
|
|
203
|
+
//
|
|
204
|
+
// The grouping key is coarse on long values on purpose — two five-hundred-character
|
|
205
|
+
// strings that differ in the middle are one finding, not two hundred — and the sentence
|
|
206
|
+
// it produced said "The same thing in 12 places" about twelve different values. An agent
|
|
207
|
+
// reading titles and counts, which is exactly what the design asks it to do, would fix the
|
|
208
|
+
// one example it was shown and take the count as proof of the other eleven. They are not
|
|
209
|
+
// the same thing, and now the sentence says so.
|
|
210
|
+
const identical = members.every((m) => sameValue(m.reference, head.reference) && sameValue(m.candidate, head.candidate));
|
|
202
211
|
|
|
203
212
|
/** @type {Finding} */
|
|
204
213
|
const finding = {
|
|
205
214
|
id: shortHash(sha256(signature)),
|
|
206
|
-
title: describe(head, count, rename),
|
|
215
|
+
title: describe(head, count, rename, identical),
|
|
207
216
|
// Provisional. rank.js replaces this once it knows how far this sits from
|
|
208
217
|
// the edit, which is the only thing that makes the sentence worth reading.
|
|
209
218
|
why: 'Not yet worked out.',
|
|
@@ -232,12 +241,20 @@ function buildFinding(signature, members, rename, sources) {
|
|
|
232
241
|
* @param {Difference} d
|
|
233
242
|
* @param {number} count
|
|
234
243
|
* @param {{from: string, to: string}} [rename]
|
|
244
|
+
* @param {boolean} [identical] True when every place in the group moved between the SAME two
|
|
245
|
+
* values. False means the same kind of change with its own
|
|
246
|
+
* values each time, and the sentence has to say which.
|
|
235
247
|
* @returns {string}
|
|
236
248
|
*/
|
|
237
|
-
export function describe(d, count, rename) {
|
|
249
|
+
export function describe(d, count, rename, identical = true) {
|
|
238
250
|
const where = CHANNEL_WORDS[d.channel] ?? 'Somewhere';
|
|
239
251
|
const name = smartLeaf(d.path);
|
|
240
|
-
const spread =
|
|
252
|
+
const spread =
|
|
253
|
+
count > 1
|
|
254
|
+
? identical
|
|
255
|
+
? ` The same thing in ${count} places.`
|
|
256
|
+
: ` The same kind of change in ${count} places, each with its own values — this is one of them, not all of them.`
|
|
257
|
+
: '';
|
|
241
258
|
|
|
242
259
|
if (rename) return `${where}, "${rename.from}" is now called "${rename.to}".${spread}`;
|
|
243
260
|
|