staysfixed 0.7.2 → 0.9.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 +429 -0
- package/README.md +193 -57
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +19 -6
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +564 -0
- package/docs/watching.md +10 -4
- package/examples/staysfixed.config.electron.js +17 -6
- package/examples/staysfixed.config.web.js +22 -5
- package/package.json +2 -1
- package/src/cli/index.js +55 -46
- package/src/cli/status.js +45 -1
- package/src/cli/watch-flags.js +54 -0
- package/src/core/config.js +54 -3
- package/src/core/paths.js +15 -0
- package/src/guard/run.js +70 -3
- package/src/report/console.js +50 -6
- package/src/run.js +11 -0
- package/src/types.js +3 -0
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/child.js +101 -0
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +18 -11
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +783 -71
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +134 -24
- package/src/v2/adapters/web.js +149 -18
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +66 -3
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +653 -69
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +65 -42
- package/src/v2/cluster.js +220 -14
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +353 -54
- package/src/v2/escalate.js +5 -1
- package/src/v2/init.js +183 -66
- package/src/v2/intent.js +9 -23
- package/src/v2/journeys/from-suite.js +336 -30
- package/src/v2/journeys/index.js +99 -6
- package/src/v2/mcp/tools.js +90 -16
- package/src/v2/normalise.js +169 -23
- package/src/v2/observation.js +19 -33
- package/src/v2/rank.js +216 -23
- package/src/v2/reference.js +160 -24
- package/src/v2/remote.js +113 -18
- package/src/v2/run.js +103 -14
- package/src/v2/sealed.js +0 -20
- package/src/v2/selfcheck.js +190 -13
- package/src/v2/ship.js +55 -5
- package/src/v2/store.js +67 -1
- package/src/v2/types.js +12 -2
- package/src/v2/waiver.js +64 -54
- package/src/v2/watch/events.js +60 -215
- package/src/v2/watch/focus.js +14 -4
- package/src/v2/watch/panel.js +167 -17
package/src/v2/run.js
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
wobbleStorm,
|
|
35
35
|
} from './observation.js';
|
|
36
36
|
import { ensureStore, saveBuild, saveCapture, latestCapture, referenceFor, listBuilds } from './store.js';
|
|
37
|
+
import { describeRuleChange } from './normalise.js';
|
|
37
38
|
import { clusterDifferences } from './cluster.js';
|
|
38
39
|
import { rankFindings } from './rank.js';
|
|
39
40
|
|
|
@@ -135,6 +136,10 @@ const VERSION = /** @type {{version?: string}} */ (require('../../package.json')
|
|
|
135
136
|
* @property {string} [journey]
|
|
136
137
|
* @property {string} [run]
|
|
137
138
|
* @property {number} [count]
|
|
139
|
+
* @property {number} [steady] Only on 'wobble'. Addresses this build answered the same
|
|
140
|
+
* way twice, counted rather than inferred by subtraction.
|
|
141
|
+
* @property {boolean} [measured] Only on 'wobble'. False when the wobble was never taken,
|
|
142
|
+
* which is not the same as a wobble of nothing.
|
|
138
143
|
* @property {number} [durationMs]
|
|
139
144
|
* @property {Verdict} [verdict]
|
|
140
145
|
*/
|
|
@@ -251,6 +256,11 @@ export async function runCheck(opts) {
|
|
|
251
256
|
/** @type {string[]} */
|
|
252
257
|
const steadyInReference = [];
|
|
253
258
|
let referenceWobbleMeasured = true;
|
|
259
|
+
// One fact about one pair of rule sets, gathered here and said once at the end.
|
|
260
|
+
/** @type {Set<string>} */
|
|
261
|
+
const rulesMoved = new Set();
|
|
262
|
+
/** @type {string[]} */
|
|
263
|
+
const rulesMovedOn = [];
|
|
254
264
|
|
|
255
265
|
for (const journey of journeys) {
|
|
256
266
|
stop();
|
|
@@ -294,12 +304,22 @@ export async function runCheck(opts) {
|
|
|
294
304
|
surface: journey.surface,
|
|
295
305
|
});
|
|
296
306
|
}
|
|
307
|
+
// ADDRESSES, not rows. This used to send `a.observations.length`, which counts every
|
|
308
|
+
// observation the adapter wrote down — and two observations at the SAME address are one
|
|
309
|
+
// address written down twice, not two addresses. Every other number on the page is
|
|
310
|
+
// counted by address: `foldCoverage` builds a Set, and the wobble arithmetic indexes by
|
|
311
|
+
// path. So a walk with two duplicate addresses put "18 addresses watched" in the header
|
|
312
|
+
// beside "14 addresses watched" in the coverage ledger and "18 answered the same way
|
|
313
|
+
// twice" for a build that only ever had 14 addresses to answer at. Three numbers about
|
|
314
|
+
// one run, disagreeing, on a page whose entire job is being believed. The duplicates
|
|
315
|
+
// themselves are not swallowed — `duplicateGaps` above reports each one.
|
|
316
|
+
const addresses = new Set(a.observations.map((o) => o.path)).size;
|
|
297
317
|
say({
|
|
298
318
|
type: 'journey:done',
|
|
299
319
|
at: events.elapsed(),
|
|
300
320
|
journey: journey.name,
|
|
301
|
-
count:
|
|
302
|
-
message: `${
|
|
321
|
+
count: addresses,
|
|
322
|
+
message: `${addresses} ${plural(addresses, 'thing', 'things')} looked at, ${wobble.unstable.length} of which this build cannot answer the same way twice.`,
|
|
303
323
|
});
|
|
304
324
|
|
|
305
325
|
if (!reference) continue;
|
|
@@ -337,9 +357,8 @@ export async function runCheck(opts) {
|
|
|
337
357
|
gaps.push({
|
|
338
358
|
what: `The old build could not be walked for "${journey.describe || journey.name}", so this was not a paired comparison after all.`,
|
|
339
359
|
why:
|
|
340
|
-
`${nameOf(reference)} was put back on this machine, and then there was nothing there to run: ${
|
|
341
|
-
|
|
342
|
-
}. This usually means the product is BUILT rather than committed — an APK, a .app, a packaged desktop app — and a checkout of the old commit does not contain one.`,
|
|
360
|
+
`${nameOf(reference)} was put back on this machine, and then there was nothing there to run: ${whyNothingRan(wasA)} ` +
|
|
361
|
+
'This usually means the product is BUILT rather than committed — an APK, a .app, a packaged desktop app — and a checkout of the old commit does not contain one.',
|
|
343
362
|
unlockedBy:
|
|
344
363
|
'Build the old commit before the run, or point the settings at a kept copy of the old build\'s artifact. Until then this journey falls back to the record the old build left last time.',
|
|
345
364
|
surface: journey.surface,
|
|
@@ -383,20 +402,43 @@ export async function runCheck(opts) {
|
|
|
383
402
|
// one normalised under another produces differences that are about the RULES — either a
|
|
384
403
|
// wall of noise that reads like a regression, or, when the change was to add a rule,
|
|
385
404
|
// quiet where there should not be any. Either way the reader has to be told.
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
405
|
+
//
|
|
406
|
+
// WHAT changed, not two hashes. This used to print "v1-fcf4b8000217 versus
|
|
407
|
+
// v1-29141a9ec069" and nothing else, which tells an agent nothing it can act on, so it
|
|
408
|
+
// acts on nothing. `describeRuleChange` owns the judgement — a rule that rewrites
|
|
409
|
+
// something differently makes the whole comparison suspect; a rule that merely reaches
|
|
410
|
+
// one more address makes only that address suspect and names it; and a record older
|
|
411
|
+
// than the scope stamp says so rather than reporting every glob as new.
|
|
412
|
+
//
|
|
413
|
+
// ONCE PER RUN, not once per journey. It is one fact about one pair of rule sets, and
|
|
414
|
+
// it was being written out again for every journey — four identical paragraphs in the
|
|
415
|
+
// coverage list on a four-journey project, on every run, saying the same thing. The
|
|
416
|
+
// coverage list is the one section that must never be skimmed, and nothing teaches a
|
|
417
|
+
// reader to skim it faster than a block that is always there and always the same.
|
|
418
|
+
const ruleChange = describeRuleChange(
|
|
419
|
+
{ fingerprint: stored.capture.rules, scope: stored.capture.rulesScope },
|
|
420
|
+
{ fingerprint: a.rules, scope: a.rulesScope },
|
|
421
|
+
);
|
|
422
|
+
if (!ruleChange.same) {
|
|
423
|
+
rulesMoved.add(ruleChange.say);
|
|
424
|
+
rulesMovedOn.push(journey.describe || journey.name);
|
|
395
425
|
}
|
|
396
426
|
if (stored.wobble) steadyInReference.push(...steadyPaths(stored.capture, stored.wobble));
|
|
397
427
|
else referenceWobbleMeasured = false;
|
|
398
428
|
}
|
|
399
429
|
|
|
430
|
+
if (rulesMovedOn.length > 0) {
|
|
431
|
+
const which =
|
|
432
|
+
rulesMovedOn.length === 1
|
|
433
|
+
? `"${rulesMovedOn[0]}" is`
|
|
434
|
+
: `${rulesMovedOn.length} journeys are`;
|
|
435
|
+
gaps.push({
|
|
436
|
+
what: `${which} being compared across a change to the normalisation rules.`,
|
|
437
|
+
why: [...rulesMoved].join(' '),
|
|
438
|
+
unlockedBy: 'Run a paired check, which walks the old build live under today\'s rules, or ship again to cut a fresh reference.',
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
|
|
400
442
|
stop();
|
|
401
443
|
// Booting the old build is not the same as having walked it. When every live walk came
|
|
402
444
|
// back holes-only — a built artifact that no checkout of the old commit contains — the
|
|
@@ -405,10 +447,19 @@ export async function runCheck(opts) {
|
|
|
405
447
|
const walkedLive = liveWalks > 0;
|
|
406
448
|
const mode = /** @type {'paired'|'stored-record'} */ (walkedLive ? 'paired' : 'stored-record');
|
|
407
449
|
const wobble = wobbles.length > 0 ? mergeWobble(wobbles) : unmeasuredWobble(opts.candidate.id, '*');
|
|
450
|
+
// `steady` and `measured` are sent because they were MEASURED here, and nothing
|
|
451
|
+
// downstream can work them out. Anything drawing this event had to guess steady as
|
|
452
|
+
// "everything watched, minus the unstable ones" — a subtraction across two different
|
|
453
|
+
// populations, so it reported addresses as having answered the same way twice when the
|
|
454
|
+
// build had never been asked at them. And `measured: false` is the difference between a
|
|
455
|
+
// build that wobbled about nothing and a build whose wobble was never taken; two noughts
|
|
456
|
+
// cannot tell those apart, and only the first one is good news.
|
|
408
457
|
say({
|
|
409
458
|
type: 'wobble',
|
|
410
459
|
at: events.elapsed(),
|
|
411
460
|
count: wobble.unstable.length,
|
|
461
|
+
steady: wobble.steady,
|
|
462
|
+
measured: wobble.measured,
|
|
412
463
|
message:
|
|
413
464
|
wobble.unstable.length === 0
|
|
414
465
|
? 'This build gives the same answer twice, everywhere.'
|
|
@@ -599,6 +650,16 @@ export async function runCheck(opts) {
|
|
|
599
650
|
cwd: opts.cwd,
|
|
600
651
|
guards: opts.guards ?? [],
|
|
601
652
|
touches: touchMap(walked),
|
|
653
|
+
// Where "your change" starts.
|
|
654
|
+
//
|
|
655
|
+
// Without this, "the change" means the working tree and nothing else — so the moment
|
|
656
|
+
// an agent commits its work, which is exactly what an agent does at the end of a task,
|
|
657
|
+
// the distance measure goes blind, the ranking loses its ordering, and every finding
|
|
658
|
+
// carries the sentence "nothing in the working tree has changed, so there is no edit
|
|
659
|
+
// to measure this against" over a change that is perfectly well known. The reference
|
|
660
|
+
// is a shipped build, which is a commit; the diff from there to here is the change,
|
|
661
|
+
// whether it has been committed or not.
|
|
662
|
+
since: reference?.gitSha ?? undefined,
|
|
602
663
|
});
|
|
603
664
|
|
|
604
665
|
const warning = modeWarning(mode, provedLive, reference);
|
|
@@ -920,6 +981,34 @@ const PROVEN_LIVE_WARNING =
|
|
|
920
981
|
const NO_REFERENCE_WARNING =
|
|
921
982
|
'Until you ship once with the reference hook in place there is nothing to compare against, so this run proves nothing about what still works.';
|
|
922
983
|
|
|
984
|
+
/**
|
|
985
|
+
* Why the old build came back with nothing that could be walked.
|
|
986
|
+
*
|
|
987
|
+
* The adapter always says why. It says it in the observation's own sentence, which
|
|
988
|
+
* `observation()` in adapters/contract.js files under `meta.describe` — "there is no APK in
|
|
989
|
+
* the exported checkout", "the runtime this needs is not on this machine". Reading only
|
|
990
|
+
* `observations[0]` threw that away in the two cases that matter most: a capture that came
|
|
991
|
+
* back completely EMPTY has no observation nought to read, so the reader got the generic
|
|
992
|
+
* "the adapter could not open it" while the real reason sat in the capture's note; and a
|
|
993
|
+
* capture whose first observation happens to be one the adapter did cover names the wrong
|
|
994
|
+
* hole. So the first REFUSED observation is the one that answers the question, its reason
|
|
995
|
+
* category stands in when it has no sentence of its own, and the capture's note is read
|
|
996
|
+
* before anything generic is said.
|
|
997
|
+
*
|
|
998
|
+
* The sentence is also finished properly. The adapter's `says` is already a whole sentence,
|
|
999
|
+
* and the old template appended a full stop of its own — the same fault already fixed once
|
|
1000
|
+
* in check.js's explain reply, which was giving agents "...real money..".
|
|
1001
|
+
*
|
|
1002
|
+
* @param {Capture} was The walk of the old build that produced nothing usable.
|
|
1003
|
+
* @returns {string} One finished sentence.
|
|
1004
|
+
*/
|
|
1005
|
+
function whyNothingRan(was) {
|
|
1006
|
+
const refused = was?.observations?.find((o) => o.meta?.refused === true);
|
|
1007
|
+
const said = refused?.meta?.describe || refused?.meta?.refusedWhy || was?.note || 'the adapter could not open it';
|
|
1008
|
+
const text = String(said).trim();
|
|
1009
|
+
return /[.!?]$/.test(text) ? text : `${text}.`;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
923
1012
|
/**
|
|
924
1013
|
* @param {'paired'|'stored-record'} mode
|
|
925
1014
|
* @param {boolean} provedLive
|
package/src/v2/sealed.js
CHANGED
|
@@ -295,26 +295,6 @@ export function classify(finding, opts = {}) {
|
|
|
295
295
|
};
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
/**
|
|
299
|
-
* The same answer as a plain class name, for callers that already work in `FindingClass`.
|
|
300
|
-
*
|
|
301
|
-
* @param {Finding} finding
|
|
302
|
-
* @param {ClassifyOptions} [opts]
|
|
303
|
-
* @returns {FindingClass}
|
|
304
|
-
*/
|
|
305
|
-
export function sealedClassOf(finding, opts = {}) {
|
|
306
|
-
return classify(finding, opts)?.class ?? 'ordinary';
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
/**
|
|
310
|
-
* @param {Finding} finding
|
|
311
|
-
* @param {ClassifyOptions} [opts]
|
|
312
|
-
* @returns {boolean}
|
|
313
|
-
*/
|
|
314
|
-
export function isSealed(finding, opts = {}) {
|
|
315
|
-
return classify(finding, opts) !== null;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
298
|
/**
|
|
319
299
|
* The refusal, written out for whoever reads it — an agent that has just been told no, or a
|
|
320
300
|
* person reading the closing summary.
|
package/src/v2/selfcheck.js
CHANGED
|
@@ -116,6 +116,49 @@ export const CASES = [
|
|
|
116
116
|
}),
|
|
117
117
|
},
|
|
118
118
|
|
|
119
|
+
{
|
|
120
|
+
// Nothing this product prints changes, deliberately. A server written straight on
|
|
121
|
+
// node:http registers nothing — there is no `app.get` for a reader to find — so before
|
|
122
|
+
// the request handler itself was read, the only honest answer here was silence, and
|
|
123
|
+
// silence is the one answer this corpus exists to make impossible.
|
|
124
|
+
name: 'a route quietly renamed in a server written by hand',
|
|
125
|
+
breaks: 'A server built straight on node:http had one of its routes renamed. Nothing it prints changes, so the only way to see it is to read the handler.',
|
|
126
|
+
expect: 'a finding',
|
|
127
|
+
mustSay: [/health/i],
|
|
128
|
+
build: (broken) => ({
|
|
129
|
+
'package.json': PKG,
|
|
130
|
+
'cli.js': [
|
|
131
|
+
"import http from 'node:http';",
|
|
132
|
+
'',
|
|
133
|
+
'const server = http.createServer((req, res) => {',
|
|
134
|
+
" if (req.url === '/orders') {",
|
|
135
|
+
" res.writeHead(200, { 'content-type': 'application/json' });",
|
|
136
|
+
' res.end(\'{"orders":2}\');',
|
|
137
|
+
' return;',
|
|
138
|
+
' }',
|
|
139
|
+
broken
|
|
140
|
+
? " if (req.url === '/healthz') {"
|
|
141
|
+
: " if (req.url === '/health') {",
|
|
142
|
+
' res.writeHead(200);',
|
|
143
|
+
' res.end(\'{"ok":true}\');',
|
|
144
|
+
' return;',
|
|
145
|
+
' }',
|
|
146
|
+
' res.writeHead(404);',
|
|
147
|
+
" res.end('not found');",
|
|
148
|
+
'});',
|
|
149
|
+
'',
|
|
150
|
+
"await new Promise((done) => server.listen(0, '127.0.0.1', done));",
|
|
151
|
+
'const address = server.address();',
|
|
152
|
+
"const port = typeof address === 'object' && address ? address.port : 0;",
|
|
153
|
+
'const reply = await fetch(`http://127.0.0.1:${port}/orders`);',
|
|
154
|
+
'console.log(`GET /orders -> ${reply.status}`);',
|
|
155
|
+
'console.log(await reply.text());',
|
|
156
|
+
'server.close();',
|
|
157
|
+
'',
|
|
158
|
+
].join('\n'),
|
|
159
|
+
}),
|
|
160
|
+
},
|
|
161
|
+
|
|
119
162
|
{
|
|
120
163
|
name: 'a field dropped from a reply',
|
|
121
164
|
breaks: 'A field quietly disappeared from a reply that everything downstream reads.',
|
|
@@ -425,6 +468,111 @@ export const CASES = [
|
|
|
425
468
|
.join('\n'),
|
|
426
469
|
}),
|
|
427
470
|
},
|
|
471
|
+
|
|
472
|
+
{
|
|
473
|
+
name: "a break only the project's own tests can see",
|
|
474
|
+
breaks:
|
|
475
|
+
'A total stops rounding. The command-line program only ever adds whole pounds, so what it prints does not move by one character and every journey this tool can discover on its own stays silent. The project\'s own test adds pennies, and it is the only thing in the repository that notices.',
|
|
476
|
+
expect: 'a finding',
|
|
477
|
+
mustSay: [/pennies/i],
|
|
478
|
+
journeys: [
|
|
479
|
+
{
|
|
480
|
+
name: 'suite-test-total-test',
|
|
481
|
+
describe: 'run the 3 checks in test/total.test.js and watch what they touch',
|
|
482
|
+
source: 'suite',
|
|
483
|
+
surface: 'cli',
|
|
484
|
+
from: 'test/total.test.js',
|
|
485
|
+
channels: ['results', 'complaints', 'counters'],
|
|
486
|
+
steps: [
|
|
487
|
+
{
|
|
488
|
+
act: 'run-tests',
|
|
489
|
+
runner: 'node:test',
|
|
490
|
+
file: 'test/total.test.js',
|
|
491
|
+
// Named rather than given as a path, so this case runs on a machine whose Node
|
|
492
|
+
// lives somewhere else. `node` is on the path of any machine that got this far.
|
|
493
|
+
command: 'node',
|
|
494
|
+
argv: ['--test', '--test-reporter=tap', 'test/total.test.js'],
|
|
495
|
+
note: 'Run this exactly as it was harvested. A test file run a different way is a different journey.',
|
|
496
|
+
},
|
|
497
|
+
],
|
|
498
|
+
},
|
|
499
|
+
],
|
|
500
|
+
build: (broken) => ({
|
|
501
|
+
'package.json': JSON.stringify(
|
|
502
|
+
{ name: 'widget', version: '1.0.0', type: 'module', bin: { widget: 'cli.js' }, scripts: { test: 'node --test' } },
|
|
503
|
+
null,
|
|
504
|
+
2,
|
|
505
|
+
) + '\n',
|
|
506
|
+
'total.js': [
|
|
507
|
+
'export function total(items) {',
|
|
508
|
+
' let sum = 0;',
|
|
509
|
+
' for (const item of items) sum += item.price;',
|
|
510
|
+
broken ? ' return sum;' : ' return Math.round(sum * 100) / 100;',
|
|
511
|
+
'}',
|
|
512
|
+
'',
|
|
513
|
+
].join('\n'),
|
|
514
|
+
'cli.js': [
|
|
515
|
+
"import { total } from './total.js';",
|
|
516
|
+
'// Whole pounds only, which is why running the product proves nothing here.',
|
|
517
|
+
'console.log(total([{ price: 2 }, { price: 3 }]));',
|
|
518
|
+
'',
|
|
519
|
+
].join('\n'),
|
|
520
|
+
'test/total.test.js': [
|
|
521
|
+
"import { test } from 'node:test';",
|
|
522
|
+
"import assert from 'node:assert/strict';",
|
|
523
|
+
"import { total } from '../total.js';",
|
|
524
|
+
'',
|
|
525
|
+
"test('adds whole pounds', () => {",
|
|
526
|
+
' assert.equal(total([{ price: 2 }, { price: 3 }]), 5);',
|
|
527
|
+
'});',
|
|
528
|
+
'',
|
|
529
|
+
"test('adds pennies without floating point dust', () => {",
|
|
530
|
+
' assert.equal(total([{ price: 0.1 }, { price: 0.2 }]), 0.3);',
|
|
531
|
+
'});',
|
|
532
|
+
'',
|
|
533
|
+
].join('\n'),
|
|
534
|
+
}),
|
|
535
|
+
},
|
|
536
|
+
|
|
537
|
+
{
|
|
538
|
+
name: 'a break behind an npm script',
|
|
539
|
+
breaks:
|
|
540
|
+
'A message changes, behind `npm run`. Nothing about the product is unusual — what this case really watches is the watcher: it rides inside the child, and until 2026-08-30 the proxy it put over the environment had no `set` trap, so npm wrote npm_lifecycle_event, read back nothing, and exited 1 without printing a word. Every product whose start command went through npm looked like a product that would not boot, on both builds equally, which is a silence rather than a finding.',
|
|
541
|
+
expect: 'a finding',
|
|
542
|
+
mustSay: [/two orders|three orders/i],
|
|
543
|
+
journeys: [
|
|
544
|
+
{
|
|
545
|
+
name: 'run-it',
|
|
546
|
+
describe: 'Run the product the way its own package.json says to run it.',
|
|
547
|
+
source: 'code',
|
|
548
|
+
surface: 'cli',
|
|
549
|
+
steps: [
|
|
550
|
+
{
|
|
551
|
+
act: 'run',
|
|
552
|
+
run: 'npm run --silent report',
|
|
553
|
+
note: 'through npm, because that is what `staysfixed init` writes by default',
|
|
554
|
+
env: {
|
|
555
|
+
// npm's own update check reaches for the registry, which is refused - and an
|
|
556
|
+
// attempt that happens on one run and not the next is noise this case would
|
|
557
|
+
// then have to argue about. Switched off rather than subtracted.
|
|
558
|
+
NO_UPDATE_NOTIFIER: '1',
|
|
559
|
+
npm_config_update_notifier: 'false',
|
|
560
|
+
npm_config_audit: 'false',
|
|
561
|
+
npm_config_fund: 'false',
|
|
562
|
+
},
|
|
563
|
+
},
|
|
564
|
+
],
|
|
565
|
+
},
|
|
566
|
+
],
|
|
567
|
+
build: (broken) => ({
|
|
568
|
+
'package.json': JSON.stringify(
|
|
569
|
+
{ name: 'widget', version: '1.0.0', type: 'module', scripts: { report: 'node report.js' } },
|
|
570
|
+
null,
|
|
571
|
+
2,
|
|
572
|
+
) + '\n',
|
|
573
|
+
'report.js': `console.log('${broken ? 'three' : 'two'} orders');\n`,
|
|
574
|
+
}),
|
|
575
|
+
},
|
|
428
576
|
];
|
|
429
577
|
|
|
430
578
|
/**
|
|
@@ -477,22 +625,51 @@ async function makeStoreUnwritable(dir, working) {
|
|
|
477
625
|
return { ready: false, why: `the warm-up run did not work: ${why(e)}` };
|
|
478
626
|
}
|
|
479
627
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
shut.push(folder);
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
if (shut.length === 0) return { ready: false, why: 'the warm-up run stored nothing, so there was nothing to make read-only' };
|
|
628
|
+
// Shut every folder under the store, not only the ones the captures land in.
|
|
629
|
+
//
|
|
630
|
+
// This walked two levels by hand — builds, then journeys — which left the build record
|
|
631
|
+
// itself writable. That is the wrong half: `openProject` writes the build record before a
|
|
632
|
+
// single journey runs, so a disk that has given out fails THERE first, and a case that
|
|
633
|
+
// only shuts the capture folders never exercises the path the real failure takes.
|
|
634
|
+
// Shutting the whole store is the mirror of `relax`, which is what puts it back.
|
|
635
|
+
const shut = await shutTight(path.join(dir, '.staysfixed', 'v2'));
|
|
636
|
+
if (shut === 0) return { ready: false, why: 'the warm-up run stored nothing, so there was nothing to make read-only' };
|
|
493
637
|
return { ready: true, why: '' };
|
|
494
638
|
}
|
|
495
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Make a folder and everything under it read-only, and say how many folders that was.
|
|
642
|
+
*
|
|
643
|
+
* The exact mirror of `relax`, which undoes it. Failures are ignored on purpose: a folder
|
|
644
|
+
* that will not change permissions is one this case cannot use, and the count tells the
|
|
645
|
+
* caller whether enough of the store was shut for the case to mean anything.
|
|
646
|
+
*
|
|
647
|
+
* @param {string} dir
|
|
648
|
+
* @returns {Promise<number>}
|
|
649
|
+
*/
|
|
650
|
+
async function shutTight(dir) {
|
|
651
|
+
/** @type {import('node:fs').Dirent[]} */
|
|
652
|
+
let inside = [];
|
|
653
|
+
try {
|
|
654
|
+
inside = await fsp.readdir(dir, { withFileTypes: true });
|
|
655
|
+
} catch {
|
|
656
|
+
return 0;
|
|
657
|
+
}
|
|
658
|
+
let shut = 0;
|
|
659
|
+
for (const entry of inside) {
|
|
660
|
+
if (entry.isDirectory()) shut += await shutTight(path.join(dir, entry.name));
|
|
661
|
+
}
|
|
662
|
+
// The folder itself last: shut it first and its own children become unreachable.
|
|
663
|
+
try {
|
|
664
|
+
await fsp.chmod(dir, 0o555);
|
|
665
|
+
shut += 1;
|
|
666
|
+
} catch {
|
|
667
|
+
// Read-only already, or a filesystem that will not say no. Either way, not a reason
|
|
668
|
+
// to stop — `relax` will still walk it, and the count tells the caller what happened.
|
|
669
|
+
}
|
|
670
|
+
return shut;
|
|
671
|
+
}
|
|
672
|
+
|
|
496
673
|
// ---------------------------------------------------------------------------
|
|
497
674
|
// Running it
|
|
498
675
|
// ---------------------------------------------------------------------------
|
package/src/v2/ship.js
CHANGED
|
@@ -156,15 +156,29 @@ export async function onShip(opts = {}) {
|
|
|
156
156
|
const store = openStore({ root });
|
|
157
157
|
await ensureStore(store);
|
|
158
158
|
|
|
159
|
-
|
|
159
|
+
/** @type {string[]} */
|
|
160
|
+
const unreadable = [];
|
|
161
|
+
const build = await resolveBuild(store, product, release, opts.build, (/** @type {string} */ why) => unreadable.push(String(why)));
|
|
162
|
+
if (unreadable.length > 0) {
|
|
163
|
+
// Said whether or not a build was found, because a damaged record changes what the
|
|
164
|
+
// answer is worth either way: if none was found it may be the reason, and if one was
|
|
165
|
+
// found it may not be the right one.
|
|
166
|
+
result.lines.push(
|
|
167
|
+
`${unreadable.length} stored ${unreadable.length === 1 ? 'record' : 'records'} of ${product} could not be read, so ${unreadable.length === 1 ? 'it was' : 'they were'} left out of this decision: ${unreadable.join('; ')}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
160
170
|
if (!build) {
|
|
161
171
|
result.cut = false;
|
|
172
|
+
const because = unreadable.length > 0
|
|
173
|
+
? ` ${unreadable.length} of its stored ${unreadable.length === 1 ? 'record' : 'records'} could not be read, which may be why.`
|
|
174
|
+
: '';
|
|
162
175
|
result.lines = [
|
|
163
176
|
`${product} ${release.describe}`,
|
|
164
|
-
`Stays Fixed has no record of this build, so it did not become the reference. Nothing about ${product} is being compared against anything yet
|
|
177
|
+
`Stays Fixed has no record of this build, so it did not become the reference. Nothing about ${product} is being compared against anything yet.${because}`,
|
|
165
178
|
'Run `staysfixed check` once before the next release and it will record itself from then on.',
|
|
179
|
+
...(unreadable.length > 0 ? [`What could not be read: ${unreadable.join('; ')}`] : []),
|
|
166
180
|
];
|
|
167
|
-
result.summary = `${product} shipped ${release.what}, but Stays Fixed had never seen this build, so what "working" means has not moved
|
|
181
|
+
result.summary = `${product} shipped ${release.what}, but Stays Fixed had never seen this build, so what "working" means has not moved.${because} Run a check before the next release.`;
|
|
168
182
|
return result;
|
|
169
183
|
}
|
|
170
184
|
|
|
@@ -381,10 +395,20 @@ async function versionBump(root) {
|
|
|
381
395
|
* @param {string} product
|
|
382
396
|
* @param {Release} release
|
|
383
397
|
* @param {string|BuildFingerprint} [told]
|
|
398
|
+
* @param {(why: string) => void} [onProblem] Told about any stored record that would not open.
|
|
384
399
|
* @returns {Promise<BuildFingerprint|null>}
|
|
385
400
|
*/
|
|
386
|
-
async function resolveBuild(store, product, release, told) {
|
|
387
|
-
|
|
401
|
+
async function resolveBuild(store, product, release, told, onProblem) {
|
|
402
|
+
// A record nobody could read must never be silently absent here.
|
|
403
|
+
//
|
|
404
|
+
// This function decides which build becomes the definition of "working" for a whole
|
|
405
|
+
// product. `listBuilds` skips a damaged record rather than failing, which is right for a
|
|
406
|
+
// listing and wrong here twice over: a build whose record will not open looks exactly like
|
|
407
|
+
// a build that was never walked, so the answer is "Stays Fixed has never seen this build"
|
|
408
|
+
// when the truth is "it has, and the record is broken"; and where two builds share a
|
|
409
|
+
// commit, losing the clean one to a damaged record leaves the dirty one to be blessed in
|
|
410
|
+
// its place. Neither is a thing to work out from an empty list.
|
|
411
|
+
const builds = await listBuilds(store, { product, onProblem });
|
|
388
412
|
|
|
389
413
|
if (told) {
|
|
390
414
|
const id = typeof told === 'string' ? told : told.id;
|
|
@@ -393,6 +417,32 @@ async function resolveBuild(store, product, release, told) {
|
|
|
393
417
|
return typeof told === 'string' ? { id: told, product } : told;
|
|
394
418
|
}
|
|
395
419
|
|
|
420
|
+
// WHAT IS ACTUALLY HERE, before what the commit says is here.
|
|
421
|
+
//
|
|
422
|
+
// Matching on the commit alone blesses the wrong thing the moment the tree is dirty:
|
|
423
|
+
// several builds share one commit, this took the first of them, and that is usually an
|
|
424
|
+
// EARLIER build — one that was checked and came back clean. So editing a file and running
|
|
425
|
+
// `staysfixed ship` answered "was already the reference — nothing changed" and exited 0,
|
|
426
|
+
// about a tree nothing had ever looked at. Measured 2026-08-30 by deleting a button and
|
|
427
|
+
// shipping without a check.
|
|
428
|
+
//
|
|
429
|
+
// A dirty tree therefore has to match EXACTLY, on the fingerprint of what is on disk right
|
|
430
|
+
// now. If nothing on record is that tree, then this tree has not been checked, and the
|
|
431
|
+
// honest answer is the one the tool already knows how to give: no record of this build, so
|
|
432
|
+
// the reference does not move.
|
|
433
|
+
try {
|
|
434
|
+
const { fingerprintWorkingTree } = await import('./check.js');
|
|
435
|
+
const here = await fingerprintWorkingTree(store.root, product);
|
|
436
|
+
if (here?.id) {
|
|
437
|
+
const exact = builds.find((b) => b.fingerprint.id === here.id);
|
|
438
|
+
if (exact) return exact.fingerprint;
|
|
439
|
+
if (here.dirty) return null;
|
|
440
|
+
}
|
|
441
|
+
} catch {
|
|
442
|
+
// No git, or the tree could not be read. The joins below are all that is left, and they
|
|
443
|
+
// are better than refusing to record a release at all.
|
|
444
|
+
}
|
|
445
|
+
|
|
396
446
|
const sha = release.gitSha;
|
|
397
447
|
if (sha) {
|
|
398
448
|
const sameCommit = builds.filter((b) => b.fingerprint.gitSha === sha);
|
package/src/v2/store.js
CHANGED
|
@@ -256,7 +256,7 @@ export async function saveCapture(store, capture) {
|
|
|
256
256
|
* clears those up.
|
|
257
257
|
*
|
|
258
258
|
* @param {Store} store
|
|
259
|
-
* @param {{build: BuildFingerprint, journey: string, run: CaptureRun, id?: string, source?: JourneySource, startedAt?: string, rules?: string}} opts
|
|
259
|
+
* @param {{build: BuildFingerprint, journey: string, run: CaptureRun, id?: string, source?: JourneySource, startedAt?: string, rules?: string, rulesScope?: Record<string, string[]>}} opts
|
|
260
260
|
* @returns {Promise<{ref: CaptureRef, append: (o: Observation) => Promise<void>, close: (end?: {durationMs?: number, coverage?: Coverage, note?: string}) => Promise<CaptureRef>, abandon: () => Promise<void>}>}
|
|
261
261
|
*/
|
|
262
262
|
export async function openCaptureWriter(store, opts) {
|
|
@@ -282,6 +282,7 @@ export async function openCaptureWriter(store, opts) {
|
|
|
282
282
|
};
|
|
283
283
|
if (opts.source) shell.source = opts.source;
|
|
284
284
|
if (opts.rules) shell.rules = opts.rules;
|
|
285
|
+
if (opts.rulesScope) shell.rulesScope = opts.rulesScope;
|
|
285
286
|
await handle.write(JSON.stringify(headerOf(shell)) + '\n');
|
|
286
287
|
|
|
287
288
|
let open = true;
|
|
@@ -367,6 +368,16 @@ async function bumpBuild(store, capture) {
|
|
|
367
368
|
}
|
|
368
369
|
|
|
369
370
|
/**
|
|
371
|
+
* The first line of a capture file.
|
|
372
|
+
*
|
|
373
|
+
* The field list is written out rather than spread, so that what reaches disk is a decision
|
|
374
|
+
* somebody made. The cost of that is this: a field added to `Capture` and not added here is
|
|
375
|
+
* silently dropped, and everything downstream reads its absence as a fact about the run. It
|
|
376
|
+
* happened to `rulesScope` — stamped on every capture, written to none of them, and the
|
|
377
|
+
* feature that reads it took the "this record predates the stamp" branch forever. If you add
|
|
378
|
+
* a field to Capture that a later run needs, add it here and to the read in `loadCapture`,
|
|
379
|
+
* and prove it with a write-then-read test rather than a unit test on the stamping.
|
|
380
|
+
*
|
|
370
381
|
* @param {Capture} capture
|
|
371
382
|
* @returns {Record<string, unknown>}
|
|
372
383
|
*/
|
|
@@ -381,6 +392,7 @@ function headerOf(capture) {
|
|
|
381
392
|
run: capture.run,
|
|
382
393
|
startedAt: capture.startedAt,
|
|
383
394
|
rules: capture.rules,
|
|
395
|
+
rulesScope: capture.rulesScope,
|
|
384
396
|
};
|
|
385
397
|
}
|
|
386
398
|
|
|
@@ -512,6 +524,7 @@ export async function loadCapture(store, where) {
|
|
|
512
524
|
};
|
|
513
525
|
if (header.source) capture.source = header.source;
|
|
514
526
|
if (header.rules) capture.rules = header.rules;
|
|
527
|
+
if (header.rulesScope) capture.rulesScope = header.rulesScope;
|
|
515
528
|
if (end?.coverage) capture.coverage = end.coverage;
|
|
516
529
|
|
|
517
530
|
const notes = [];
|
|
@@ -824,6 +837,59 @@ export async function pruneBuild(store, buildId, opts = {}) {
|
|
|
824
837
|
return { removed, kept };
|
|
825
838
|
}
|
|
826
839
|
|
|
840
|
+
/**
|
|
841
|
+
* Throw the whole of a build away: its record, its captures, its folder.
|
|
842
|
+
*
|
|
843
|
+
* The companion `pruneBuild` thins one build's captures down to the newest few, which is the
|
|
844
|
+
* right tool when a build is worth keeping and its hundred captures are not. It is the wrong
|
|
845
|
+
* tool for the growth anybody actually measures: one build FOLDER per check, forever, in a
|
|
846
|
+
* directory this tool asks people to commit. Nothing could remove a folder at all, so the only
|
|
847
|
+
* housekeeping that existed could not touch the thing that grows.
|
|
848
|
+
*
|
|
849
|
+
* The two refusals are the same two, for the same reason, and they are loud rather than quiet
|
|
850
|
+
* because deleting the evidence of what "working" means is not recoverable:
|
|
851
|
+
*
|
|
852
|
+
* - A build any product points at as its reference is never removed. Its captures are the
|
|
853
|
+
* only record of what working looked like, and once they are gone the next check has
|
|
854
|
+
* nothing to compare against.
|
|
855
|
+
* - A build whose own record cannot be read is never removed. An unreadable record is the
|
|
856
|
+
* one state where we do not know what we would be deleting, and "I could not tell, so I
|
|
857
|
+
* deleted it" is the wrong way round.
|
|
858
|
+
*
|
|
859
|
+
* WHAT TO KEEP IS NOT DECIDED HERE. This removes one build that has been named. Which builds
|
|
860
|
+
* are worth keeping — how many, how long, whether a `work-` build off a dirty tree is worth
|
|
861
|
+
* less than a `git-` one off a commit — is a policy about somebody's disk and their history,
|
|
862
|
+
* and it belongs where the run knows what it just did, not in the file that owns the folder.
|
|
863
|
+
*
|
|
864
|
+
* @param {Store} store
|
|
865
|
+
* @param {string} buildId
|
|
866
|
+
* @returns {Promise<{removed: true, captures: number}>}
|
|
867
|
+
*/
|
|
868
|
+
export async function removeBuild(store, buildId) {
|
|
869
|
+
const record = await loadBuild(store, buildId);
|
|
870
|
+
const references = await loadReferences(store);
|
|
871
|
+
|
|
872
|
+
// Asked of the POINTERS rather than of the build's own record, for the reason spelled out in
|
|
873
|
+
// pruneBuild: a build whose build.json is missing answers nothing, and reading it the other
|
|
874
|
+
// way round would skip the guard exactly when it matters most.
|
|
875
|
+
const pointedAt = Object.values(references).filter((p) => p?.buildId === buildId);
|
|
876
|
+
if (pointedAt.length > 0) {
|
|
877
|
+
throw new StaysFixedError(
|
|
878
|
+
`${buildId} is the reference for ${pointedAt.map((p) => p.product).join(', ')}, so it cannot be thrown away.`,
|
|
879
|
+
{ hint: 'Point the reference at a newer build first, with setReference.' },
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
if (!record) {
|
|
883
|
+
throw new StaysFixedError(`Nothing here says what ${buildId} is, so it will not be thrown away.`, {
|
|
884
|
+
hint: 'Its build.json is missing. Deleting a build on the strength of a record nobody can read is how the evidence for "this used to work" disappears. Run a check against it to rewrite the record, or delete the folder deliberately.',
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const captures = (await listCaptures(store, { buildId })).length;
|
|
889
|
+
await fsp.rm(buildDir(store, buildId), { recursive: true, force: true });
|
|
890
|
+
return { removed: true, captures };
|
|
891
|
+
}
|
|
892
|
+
|
|
827
893
|
/**
|
|
828
894
|
* Delete `.part` files left behind by runs that died.
|
|
829
895
|
*
|