staysfixed 0.7.1 → 0.8.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 +364 -0
- package/README.md +193 -55
- package/docs/design-v2.md +24 -4
- package/docs/getting-started.md +18 -5
- package/docs/guards.md +2 -2
- package/docs/how-v2-works.md +12 -11
- package/docs/mcp.md +17 -8
- package/docs/settings.md +549 -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/watch-flags.js +54 -0
- package/src/core/config.js +23 -3
- package/src/guard/run.js +49 -1
- package/src/report/console.js +15 -2
- package/src/v2/adapters/android-driver.js +6 -1
- package/src/v2/adapters/android.js +97 -2
- package/src/v2/adapters/contract.js +42 -5
- package/src/v2/adapters/electron.js +72 -6
- package/src/v2/adapters/http.js +11 -2
- package/src/v2/adapters/ios-driver.js +64 -14
- package/src/v2/adapters/ios.js +247 -25
- package/src/v2/adapters/process.js +728 -66
- package/src/v2/adapters/python.js +495 -0
- package/src/v2/adapters/source.js +373 -18
- package/src/v2/adapters/web-driver.js +94 -24
- package/src/v2/adapters/web.js +142 -9
- package/src/v2/adapters/windows.js +18 -1
- package/src/v2/browsers.js +9 -1
- package/src/v2/cause.js +61 -17
- package/src/v2/check.js +530 -66
- package/src/v2/ci.js +130 -35
- package/src/v2/cli.js +42 -24
- package/src/v2/cluster.js +164 -13
- package/src/v2/coverage.js +43 -176
- package/src/v2/detect.js +308 -60
- package/src/v2/doctor.js +345 -47
- package/src/v2/init.js +162 -61
- 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 +10 -11
- 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 +40 -10
- 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 +29 -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/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;
|
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
|
*
|
package/src/v2/types.js
CHANGED
|
@@ -152,7 +152,6 @@
|
|
|
152
152
|
* @property {string|null} [branch]
|
|
153
153
|
* @property {boolean} [dirty] Working tree had uncommitted changes.
|
|
154
154
|
* @property {string} [artifact] Path to the built thing. NOT stored here — see store.js.
|
|
155
|
-
* @property {string} [artifactSha256]
|
|
156
155
|
* @property {string} [builtAt] ISO timestamp.
|
|
157
156
|
* @property {string} [platform] e.g. 'darwin-arm64'. Comparing across platforms warns.
|
|
158
157
|
* @property {string} [tool] Stays Fixed version that captured it.
|
|
@@ -183,7 +182,12 @@
|
|
|
183
182
|
* @property {Coverage} [coverage] What this capture did NOT manage to look at.
|
|
184
183
|
* @property {boolean} [complete] False when the file was read back torn — see store.js.
|
|
185
184
|
* @property {string} [note]
|
|
186
|
-
* @property {string} [rules]
|
|
185
|
+
* @property {string} [rules] Fingerprint of what the normalisation rules DO, if any
|
|
186
|
+
* were applied. Scope is stamped separately — see rulesScope.
|
|
187
|
+
* @property {Record<string, string[]>} [rulesScope]
|
|
188
|
+
* Where each scoped rule applied, by rule id. Absent on
|
|
189
|
+
* captures written before this was stamped, which is a real
|
|
190
|
+
* state and says "cannot be compared" rather than "nothing".
|
|
187
191
|
*/
|
|
188
192
|
|
|
189
193
|
// ---------------------------------------------------------------------------
|
|
@@ -438,6 +442,12 @@ export {};
|
|
|
438
442
|
* '$.items.3.name'. Used by sort, round and drop.
|
|
439
443
|
* @property {boolean} [off] Shipped, documented, and not switched on.
|
|
440
444
|
* @property {string} [whyOff] Why it is not on by default.
|
|
445
|
+
* @property {boolean} [machine] This rule's pattern is a fact about THIS machine — where
|
|
446
|
+
* the project is checked out, where home is, where the temp
|
|
447
|
+
* folder went today — rather than a decision about what to
|
|
448
|
+
* tidy. Two machines running the same rule write the same
|
|
449
|
+
* placeholder, so the rule is the same rule and its pattern
|
|
450
|
+
* must not reach the fingerprint. See rulesFingerprint.
|
|
441
451
|
*/
|
|
442
452
|
|
|
443
453
|
/**
|
package/src/v2/waiver.js
CHANGED
|
@@ -36,7 +36,6 @@
|
|
|
36
36
|
* different difference and is reported.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
import fsp from 'node:fs/promises';
|
|
40
39
|
import path from 'node:path';
|
|
41
40
|
import crypto from 'node:crypto';
|
|
42
41
|
|
|
@@ -48,9 +47,10 @@ import {
|
|
|
48
47
|
readIntent,
|
|
49
48
|
readIntentById,
|
|
50
49
|
referenceStamp,
|
|
50
|
+
fingerprintTree,
|
|
51
|
+
treeMovedSince,
|
|
51
52
|
readJsonFile,
|
|
52
53
|
writeJsonAtomic,
|
|
53
|
-
shortDigest,
|
|
54
54
|
} from './intent.js';
|
|
55
55
|
|
|
56
56
|
/**
|
|
@@ -71,10 +71,15 @@ import {
|
|
|
71
71
|
*/
|
|
72
72
|
export const WAIVER_BUDGET = 5;
|
|
73
73
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
/**
|
|
75
|
+
* How many dead waivers are kept per product, so a summary can still say what expired.
|
|
76
|
+
*
|
|
77
|
+
* LIVE ONES ARE NEVER PRUNED, which is what matters: the budget, the gates and whether a
|
|
78
|
+
* difference is covered are all worked out from those alone and none of them can be affected
|
|
79
|
+
* by this number. What it can affect is the sentence "three waivers expired when you
|
|
80
|
+
* shipped", which counts the dead ones still on disk — so after fifty of them, spread over
|
|
81
|
+
* ten ships or more, that number is the recent history rather than the whole of it.
|
|
82
|
+
*/
|
|
78
83
|
const KEEP_EXPIRED = 50;
|
|
79
84
|
|
|
80
85
|
// ---------------------------------------------------------------------------
|
|
@@ -94,12 +99,22 @@ const KEEP_EXPIRED = 50;
|
|
|
94
99
|
* @property {string} fingerprint Pins the exact difference, values included.
|
|
95
100
|
* @property {string} finding The finding's own id, when it had one.
|
|
96
101
|
* @property {string} summary The finding's title, kept so this reads without a check.
|
|
97
|
-
* @property {string[]} paths The addresses involved,
|
|
102
|
+
* @property {string[]} paths The first few addresses involved, for somebody reading
|
|
103
|
+
* this back later. Nothing is decided from them: what the
|
|
104
|
+
* waiver actually covers is `fingerprint`, which takes in
|
|
105
|
+
* every difference with no ceiling at all.
|
|
98
106
|
* @property {FindingClass} class What the engine called it. Always ordinary — see gate 1.
|
|
99
107
|
* @property {string} why The agent's reason, in its own words.
|
|
100
108
|
* @property {string} intentId
|
|
101
109
|
* @property {string} intentSummary Copied, so a pruned intent does not orphan the waiver.
|
|
102
110
|
* @property {string} ordering What was known about when the intent was sealed.
|
|
111
|
+
* @property {{moved: boolean, knowable: boolean, say: string}} codeSince
|
|
112
|
+
* Whether the code moved between sealing that intent and
|
|
113
|
+
* writing this. Not a gate — a moved tree is what an
|
|
114
|
+
* intent sealed BEFORE the work is supposed to look like.
|
|
115
|
+
* It is recorded because the alternative is that nobody
|
|
116
|
+
* reading this waiver a month later can tell whether the
|
|
117
|
+
* intent describes the build that was actually checked.
|
|
103
118
|
* @property {IntentCoverage} coverage How well it matched what was declared, and how sure.
|
|
104
119
|
* @property {string} at ISO. Written here, never supplied.
|
|
105
120
|
* @property {string} [by]
|
|
@@ -284,6 +299,12 @@ export async function waive(store, what) {
|
|
|
284
299
|
};
|
|
285
300
|
}
|
|
286
301
|
|
|
302
|
+
// The last of the three things intent.js says its tree fingerprint makes checkable. The
|
|
303
|
+
// other two are gates above; this one cannot be, because both answers are legitimate — an
|
|
304
|
+
// intent sealed before the work SHOULD see a moved tree, and one sealed after it should
|
|
305
|
+
// not. So it is written down rather than judged, and a person reading the waiver decides.
|
|
306
|
+
const codeSince = treeMovedSince(intent, await fingerprintTree(store.root));
|
|
307
|
+
|
|
287
308
|
/** @type {Waiver} */
|
|
288
309
|
const waiver = {
|
|
289
310
|
id: `waiver-${crypto.randomBytes(5).toString('hex')}`,
|
|
@@ -297,6 +318,7 @@ export async function waive(store, what) {
|
|
|
297
318
|
intentId: intent.id,
|
|
298
319
|
intentSummary: intent.summary,
|
|
299
320
|
ordering: intent.ordering,
|
|
321
|
+
codeSince,
|
|
300
322
|
coverage,
|
|
301
323
|
at: new Date().toISOString(),
|
|
302
324
|
reference: stamp,
|
|
@@ -318,6 +340,7 @@ export async function waive(store, what) {
|
|
|
318
340
|
`Recorded as intended: ${waiver.summary}`,
|
|
319
341
|
`Your reason, kept: ${why}`,
|
|
320
342
|
`Matched against what you sealed: ${coverage.why} (${coverage.confidence} match)`,
|
|
343
|
+
codeSince.say,
|
|
321
344
|
'',
|
|
322
345
|
`${left} of your ${WAIVER_BUDGET} waivers left before the next ship. This one is pinned to the exact values that differ and to the reference in force now: if either moves, it stops covering anything.`,
|
|
323
346
|
'This is not approval. Nothing becomes the new normal until a build ships. Say in what you report back that you waived this, and why.',
|
|
@@ -371,45 +394,6 @@ export function waiverFor(waivers, finding) {
|
|
|
371
394
|
return waivers.find((w) => w.fingerprint === fingerprint) ?? null;
|
|
372
395
|
}
|
|
373
396
|
|
|
374
|
-
/**
|
|
375
|
-
* What the closing summary needs: how many were waived, how many are left, what expired, and one
|
|
376
|
-
* sentence saying so.
|
|
377
|
-
*
|
|
378
|
-
* Waivers must be visible, not quiet. This is the function that makes them so, and a summary
|
|
379
|
-
* that does not use it is hiding something an agent decided on its own.
|
|
380
|
-
*
|
|
381
|
-
* @param {Store} store
|
|
382
|
-
* @param {string} product
|
|
383
|
-
* @returns {Promise<{budget: number, spent: number, left: number, active: Waiver[], expired: number, reference: string, line: string}>}
|
|
384
|
-
*/
|
|
385
|
-
export async function countWaivers(store, product) {
|
|
386
|
-
const stamp = await referenceStamp(store, product);
|
|
387
|
-
const all = await allWaivers(store, product);
|
|
388
|
-
const active = all.filter((w) => isLive(w, stamp));
|
|
389
|
-
const expired = all.length - active.length;
|
|
390
|
-
const left = Math.max(0, WAIVER_BUDGET - active.length);
|
|
391
|
-
|
|
392
|
-
const line =
|
|
393
|
-
active.length === 0
|
|
394
|
-
? `Nothing was waived${expired > 0 ? `, and ${expired} older waiver${expired === 1 ? '' : 's'} died when the reference last moved` : ''}.`
|
|
395
|
-
: `${active.length} difference${active.length === 1 ? ' was' : 's were'} recorded as intended, not approved: ${active
|
|
396
|
-
.map((w) => trim(w.summary, 90))
|
|
397
|
-
.join('; ')}. ${left} of the ${WAIVER_BUDGET} allowed before a person has to look ${left === 1 ? 'is' : 'are'} left.`;
|
|
398
|
-
|
|
399
|
-
return { budget: WAIVER_BUDGET, spent: active.length, left, active, expired, reference: stamp, line };
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
/**
|
|
403
|
-
* Forget a product's waivers. Housekeeping, and the way a test starts clean.
|
|
404
|
-
*
|
|
405
|
-
* @param {Store} store
|
|
406
|
-
* @param {string} product
|
|
407
|
-
* @returns {Promise<void>}
|
|
408
|
-
*/
|
|
409
|
-
export async function forgetWaivers(store, product) {
|
|
410
|
-
await fsp.rm(waiversFile(store, product), { force: true });
|
|
411
|
-
}
|
|
412
|
-
|
|
413
397
|
/**
|
|
414
398
|
* What a waiver is pinned to.
|
|
415
399
|
*
|
|
@@ -418,20 +402,46 @@ export async function forgetWaivers(store, product) {
|
|
|
418
402
|
* which errs towards a person looking at something they have already seen rather than towards a
|
|
419
403
|
* new break hiding behind an old excuse. That is the right way round.
|
|
420
404
|
*
|
|
405
|
+
* EVERY difference, and there is deliberately no ceiling on that. Until 2026-08-30 this took
|
|
406
|
+
* the first forty and stopped, and the sentence above was simply false: a waiver written about
|
|
407
|
+
* a three-hundred-address finding went on covering it after a value past the fortieth turned
|
|
408
|
+
* into something else. The addresses were all still there, the title still read the same, the
|
|
409
|
+
* cluster was still one finding — so the pin matched, the difference was filed as intended, and
|
|
410
|
+
* nobody was ever shown the one row that had actually broken. That is the whole failure this
|
|
411
|
+
* file exists to prevent, arriving through the file itself.
|
|
412
|
+
*
|
|
413
|
+
* The cost of having no ceiling is a hash over text the caller is already holding in memory,
|
|
414
|
+
* which is nothing next to being wrong. The tuples are sorted first so that two runs which
|
|
415
|
+
* found the same differences in a different order still pin to the same thing; the number of
|
|
416
|
+
* them goes in as well, so a cluster that merely GREW cannot match a waiver written about the
|
|
417
|
+
* smaller one.
|
|
418
|
+
*
|
|
421
419
|
* @param {Finding} finding
|
|
422
420
|
* @returns {string}
|
|
423
421
|
*/
|
|
424
422
|
export function fingerprintFinding(finding) {
|
|
425
|
-
const differences =
|
|
426
|
-
|
|
427
|
-
|
|
423
|
+
const differences = finding.differences ?? [];
|
|
424
|
+
const hash = crypto.createHash('sha256');
|
|
425
|
+
/** @param {unknown} part */
|
|
426
|
+
const eat = (part) => {
|
|
427
|
+
// JSON escapes every newline inside a value, so a newline is a separator nothing in the
|
|
428
|
+
// text can forge — two different findings cannot run together into one identical digest.
|
|
429
|
+
hash.update(`${JSON.stringify(part) ?? 'null'}\n`);
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
eat(finding.title ?? '');
|
|
433
|
+
eat([...(finding.paths ?? [])].sort());
|
|
434
|
+
eat(differences.length);
|
|
435
|
+
const rows = differences.map((d) => JSON.stringify([d.path, d.kind, face(d.reference), face(d.candidate)]));
|
|
436
|
+
rows.sort();
|
|
437
|
+
for (const row of rows) hash.update(`${row}\n`);
|
|
438
|
+
|
|
428
439
|
// A finding with no differences attached, which some callers pass, still has to be pinnable,
|
|
429
440
|
// so the sample and the paths stand in for them.
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
return shortDigest([finding.title ?? '', [...(finding.paths ?? [])].sort(), differences, fallback]);
|
|
441
|
+
if (differences.length === 0) {
|
|
442
|
+
eat([finding.sample?.path ?? '', finding.sample?.kind ?? '', face(finding.sample?.reference), face(finding.sample?.candidate)]);
|
|
443
|
+
}
|
|
444
|
+
return hash.digest('hex').slice(0, 16);
|
|
435
445
|
}
|
|
436
446
|
|
|
437
447
|
// ---------------------------------------------------------------------------
|