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/rank.js
CHANGED
|
@@ -55,6 +55,14 @@ const NOT_SOURCE = new Set([
|
|
|
55
55
|
|
|
56
56
|
const SOURCE_EXTENSIONS = ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.mts', '.cts', '.vue', '.svelte'];
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* How many files a journey may touch and still be used to say where a finding lives.
|
|
60
|
+
*
|
|
61
|
+
* A journey that went through four hundred files touches everything, and letting it answer
|
|
62
|
+
* would put every finding at distance zero and quietly switch the whole ranking off.
|
|
63
|
+
*/
|
|
64
|
+
const MAX_JOURNEY_FILES = 25;
|
|
65
|
+
|
|
58
66
|
/** Ceilings, so a check on a very large repo never turns into a crawl of it. */
|
|
59
67
|
const MAX_FILES = 4000;
|
|
60
68
|
const MAX_FILE_BYTES = 400_000;
|
|
@@ -122,6 +130,14 @@ const CHANNEL_WEIGHT = {
|
|
|
122
130
|
* @property {boolean} beyond The source file is in this project and no path of at
|
|
123
131
|
* most `maxHops` imports leads to it from anything changed.
|
|
124
132
|
* @property {string} [beyondFile] Which file, so the sentence can name it.
|
|
133
|
+
* @property {{journey: string, files: number}} [tooBroad]
|
|
134
|
+
* Nothing named the finding's own source, and the journey
|
|
135
|
+
* it came from touched so many files that using them would
|
|
136
|
+
* have put everything at distance zero. That is a THIRD
|
|
137
|
+
* kind of not knowing, and until 2026-08-30 it was worded
|
|
138
|
+
* as the first — the reader was told nothing said which
|
|
139
|
+
* code this came from, when in fact plenty did and all of
|
|
140
|
+
* it was too broad to be worth anything.
|
|
125
141
|
*/
|
|
126
142
|
|
|
127
143
|
/**
|
|
@@ -151,6 +167,17 @@ const CHANNEL_WEIGHT = {
|
|
|
151
167
|
* everything downstream has to be told which of the two
|
|
152
168
|
* it is looking at.
|
|
153
169
|
* @property {string} [patchUnreadWhy]
|
|
170
|
+
* @property {string} base What the diff was measured FROM: 'HEAD' when only the
|
|
171
|
+
* working tree was read, or the reference build's commit when
|
|
172
|
+
* one was named. A hunk in this patch can only be reverted in
|
|
173
|
+
* a checkout of THIS commit, which is why it travels with it.
|
|
174
|
+
* @property {boolean} committed True when the diff was measured from the reference build's
|
|
175
|
+
* commit rather than from HEAD, so work that has already been
|
|
176
|
+
* committed is inside it too. False means the working tree was
|
|
177
|
+
* all that was read, and nothing downstream may then say a
|
|
178
|
+
* word about whether the product changed — only about whether
|
|
179
|
+
* the working tree did.
|
|
180
|
+
* @property {string} [baseWhy] Why the commit that was asked for was not used.
|
|
154
181
|
*/
|
|
155
182
|
|
|
156
183
|
/**
|
|
@@ -163,15 +190,21 @@ const CHANNEL_WEIGHT = {
|
|
|
163
190
|
* guards?: string[],
|
|
164
191
|
* touches?: Record<string, string[]>,
|
|
165
192
|
* changed?: Changed,
|
|
193
|
+
* since?: string,
|
|
166
194
|
* maxHops?: number,
|
|
167
195
|
* }} opts
|
|
196
|
+
* `since` is the reference build's commit. Pass it and a change that has been COMMITTED is
|
|
197
|
+
* measured like any other; leave it out and only the working tree is read, which goes blind
|
|
198
|
+
* the moment an agent commits its work — and committing at the end of a task is what an
|
|
199
|
+
* agent does.
|
|
168
200
|
* @returns {Promise<{findings: Finding[], notes: string[], youChanged: string[]}>}
|
|
169
201
|
*/
|
|
170
202
|
export async function rankFindings(findings, opts) {
|
|
171
203
|
/** @type {string[]} */
|
|
172
204
|
const notes = [];
|
|
173
205
|
const guards = opts.guards ?? [];
|
|
174
|
-
const changed = opts.changed ?? (await whatChanged(opts.cwd));
|
|
206
|
+
const changed = opts.changed ?? (await whatChanged(opts.cwd, { since: opts.since }));
|
|
207
|
+
if (changed.baseWhy) notes.push(changed.baseWhy);
|
|
175
208
|
if (!changed.ok && changed.why) {
|
|
176
209
|
notes.push(
|
|
177
210
|
`${changed.why} Findings are ordered by what kind of thing they are instead of by how far they sit from your edit.`,
|
|
@@ -208,13 +241,20 @@ export async function rankFindings(findings, opts) {
|
|
|
208
241
|
`${graph.unreadable.length} source ${graph.unreadable.length === 1 ? 'file' : 'files'} could not be opened for the distance measure: ${graph.unreadable.slice(0, 3).join(', ')}. The same warning applies — what they import looks unconnected.`,
|
|
209
242
|
);
|
|
210
243
|
}
|
|
244
|
+
if (graph.unreadableDirs.length > 0) {
|
|
245
|
+
notes.push(
|
|
246
|
+
`${graph.unreadableDirs.length} ${graph.unreadableDirs.length === 1 ? 'folder' : 'folders'} could not be opened for the distance measure: ${graph.unreadableDirs.slice(0, 3).join(', ')}${graph.unreadableDirs.length > 3 ? ', and others' : ''}. Nothing inside ${graph.unreadableDirs.length === 1 ? 'it' : 'them'} is in the graph at all, so a difference coming from there cannot be placed near or far from your edit.`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
211
249
|
distances = distancesFrom(graph.neighbours, seeds, hops);
|
|
212
250
|
} else if (changed.ok && changed.patchUnread !== true) {
|
|
213
|
-
notes.push(
|
|
251
|
+
notes.push(`${nothingChanged(changed)} None of this can be blamed on an edit you just made.`);
|
|
214
252
|
}
|
|
215
253
|
|
|
254
|
+
/** @type {string[]} */
|
|
255
|
+
const blindSpots = [];
|
|
216
256
|
const ranked = findings.map((finding) => {
|
|
217
|
-
const sealedClass = classOf(finding, guards);
|
|
257
|
+
const sealedClass = classOf(finding, guards, (what) => blindSpots.push(what));
|
|
218
258
|
const how = distanceFor(finding, distances, changed.root, opts.touches ?? {}, known);
|
|
219
259
|
/** @type {Finding} */
|
|
220
260
|
const out = {
|
|
@@ -222,13 +262,19 @@ export async function rankFindings(findings, opts) {
|
|
|
222
262
|
class: sealedClass,
|
|
223
263
|
sealed: sealedClass !== 'ordinary',
|
|
224
264
|
rank: scoreOf(finding, sealedClass, how),
|
|
225
|
-
why: explain(finding, sealedClass, how, seeds.length > 0, hops),
|
|
265
|
+
why: explain(finding, sealedClass, how, seeds.length > 0, hops, changed),
|
|
226
266
|
};
|
|
227
267
|
const near = nearestFiles(finding, distances, changed.root);
|
|
228
268
|
if (near.length > 0) out.nearFiles = near;
|
|
229
269
|
return out;
|
|
230
270
|
});
|
|
231
271
|
|
|
272
|
+
if (blindSpots.length > 0) {
|
|
273
|
+
notes.push(
|
|
274
|
+
`${blindSpots.slice(0, 3).join(' ')}${blindSpots.length > 3 ? ` And ${blindSpots.length - 3} more like it.` : ''} Anything filed as ordinary that came from one of those is ordinary only as far as anybody could see.`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
232
278
|
ranked.sort((a, b) => b.rank - a.rank || (b.count ?? 0) - (a.count ?? 0) || a.id.localeCompare(b.id));
|
|
233
279
|
return { findings: ranked, notes, youChanged: [...changed.files, ...changed.untracked] };
|
|
234
280
|
}
|
|
@@ -246,9 +292,13 @@ export async function rankFindings(findings, opts) {
|
|
|
246
292
|
*
|
|
247
293
|
* @param {Finding} finding
|
|
248
294
|
* @param {string[]} guards Guard names, so a difference touching one is sealed by name.
|
|
295
|
+
* @param {(what: string) => void} [onBlind] Told when a value could not be turned into text
|
|
296
|
+
* at all. That value was not searched for money, signing in, losing data, a crash or a
|
|
297
|
+
* guard — so `ordinary` coming back means "nothing found in what could be read", which is
|
|
298
|
+
* not the same claim and must not be made silently.
|
|
249
299
|
* @returns {FindingClass}
|
|
250
300
|
*/
|
|
251
|
-
export function classOf(finding, guards) {
|
|
301
|
+
export function classOf(finding, guards, onBlind) {
|
|
252
302
|
const haystack = [
|
|
253
303
|
finding.title,
|
|
254
304
|
finding.signature ?? '',
|
|
@@ -272,9 +322,27 @@ export function classOf(finding, guards) {
|
|
|
272
322
|
// and diffed several times over.
|
|
273
323
|
/** @type {string[]} */
|
|
274
324
|
const values = [];
|
|
325
|
+
let blind = 0;
|
|
326
|
+
/** @param {unknown} value */
|
|
327
|
+
const add = (value) => {
|
|
328
|
+
const said = asText(value);
|
|
329
|
+
if (said.ok) values.push(said.text);
|
|
330
|
+
else blind += 1;
|
|
331
|
+
};
|
|
275
332
|
for (const d of finding.differences) {
|
|
276
|
-
if (d.reference !== undefined)
|
|
277
|
-
if (d.candidate !== undefined)
|
|
333
|
+
if (d.reference !== undefined) add(d.reference);
|
|
334
|
+
if (d.candidate !== undefined) add(d.candidate);
|
|
335
|
+
}
|
|
336
|
+
// A value that will not turn into text — something holding a loop, a BigInt — used to come
|
|
337
|
+
// back as the empty string and go into the search alongside the real ones. Nothing matched
|
|
338
|
+
// in it, of course, and the finding was filed `ordinary`, which is exactly the class an
|
|
339
|
+
// agent is allowed to wave through on its own. The whole point of this function is that a
|
|
340
|
+
// crash or a charge cannot be waved through, so being unable to look inside a value has to
|
|
341
|
+
// travel out of here rather than be spent as a quiet nothing-found.
|
|
342
|
+
if (blind > 0 && onBlind) {
|
|
343
|
+
onBlind(
|
|
344
|
+
`${blind} ${blind === 1 ? 'value' : 'values'} in "${finding.title}" could not be read as text, so nothing looked inside ${blind === 1 ? 'it' : 'them'} for money, signing in, losing data, a crash or a guard.`,
|
|
345
|
+
);
|
|
278
346
|
}
|
|
279
347
|
/** @param {RegExp} rx */
|
|
280
348
|
const says = (rx) => rx.test(haystack) || values.some((v) => rx.test(v));
|
|
@@ -312,14 +380,30 @@ export function classOf(finding, guards) {
|
|
|
312
380
|
* empty string rather than taking the run down over a sealing check.
|
|
313
381
|
*
|
|
314
382
|
* @param {unknown} value
|
|
315
|
-
* @returns {string}
|
|
383
|
+
* @returns {{ok: boolean, text: string}} `ok` is false only when the value could not be
|
|
384
|
+
* turned into anything searchable at all. An empty string that came back from a value with
|
|
385
|
+
* genuinely no words in it, and an empty string standing in for a value nobody could read,
|
|
386
|
+
* used to be indistinguishable here.
|
|
316
387
|
*/
|
|
317
388
|
function asText(value) {
|
|
318
|
-
if (typeof value === 'string') return value;
|
|
389
|
+
if (typeof value === 'string') return { ok: true, text: value };
|
|
319
390
|
try {
|
|
320
|
-
|
|
391
|
+
const json = JSON.stringify(value);
|
|
392
|
+
// undefined, a function or a symbol. There are no words in any of those, and saying so
|
|
393
|
+
// is a true answer rather than a failure.
|
|
394
|
+
if (json === undefined) return { ok: true, text: '' };
|
|
395
|
+
return { ok: true, text: json };
|
|
321
396
|
} catch {
|
|
322
|
-
|
|
397
|
+
// A BigInt, or a value holding a loop. Last try: whatever it says about itself.
|
|
398
|
+
try {
|
|
399
|
+
const said = String(value);
|
|
400
|
+
// "[object Object]" is not the value, it is the shape of the value, and searching it
|
|
401
|
+
// for the word "charge" proves nothing at all.
|
|
402
|
+
if (said && said !== '[object Object]') return { ok: true, text: said };
|
|
403
|
+
} catch {
|
|
404
|
+
// Even its own toString threw. There is nothing left to read.
|
|
405
|
+
}
|
|
406
|
+
return { ok: false, text: '' };
|
|
323
407
|
}
|
|
324
408
|
}
|
|
325
409
|
|
|
@@ -359,17 +443,21 @@ function scoreOf(finding, sealedClass, how) {
|
|
|
359
443
|
* @param {HowFar} how
|
|
360
444
|
* @param {boolean} knewWhatChanged
|
|
361
445
|
* @param {number} hops How far out the measure walked before it stopped.
|
|
446
|
+
* @param {Changed} changed
|
|
362
447
|
*/
|
|
363
|
-
function explain(finding, sealedClass, how, knewWhatChanged, hops) {
|
|
448
|
+
function explain(finding, sealedClass, how, knewWhatChanged, hops, changed) {
|
|
364
449
|
if (sealedClass !== 'ordinary') {
|
|
365
450
|
return `Nobody may wave this through on their own: it touches ${SEAL_WORDS[sealedClass]}. It goes to a person whatever caused it.`;
|
|
366
451
|
}
|
|
367
452
|
if (!knewWhatChanged) {
|
|
368
|
-
return
|
|
453
|
+
return `${nothingChanged(changed)} There is no edit to measure this against.`;
|
|
369
454
|
}
|
|
370
455
|
if (how.beyond) {
|
|
371
456
|
return `This comes from ${how.beyondFile}, which is source code the project has and which nothing you changed reaches within ${hops} steps. That is as far from your edit as this measure goes — the strongest shape a side effect has.`;
|
|
372
457
|
}
|
|
458
|
+
if (how.tooBroad) {
|
|
459
|
+
return `The only thing that says where this comes from is "${how.tooBroad.journey}", and that journey goes through ${how.tooBroad.files} files — too many for it to mean anything about where this lives, so how far it sits from your edit is unknown. Treat it as unexplained until you have checked.`;
|
|
460
|
+
}
|
|
373
461
|
if (how.distance === null) {
|
|
374
462
|
return 'Nothing says which code this comes from, so how far it sits from your edit is unknown. Treat it as unexplained until you have checked.';
|
|
375
463
|
}
|
|
@@ -378,6 +466,23 @@ function explain(finding, sealedClass, how, knewWhatChanged, hops) {
|
|
|
378
466
|
return `This is ${how.distance} steps away from anything you changed. That is what a side effect looks like.`;
|
|
379
467
|
}
|
|
380
468
|
|
|
469
|
+
/**
|
|
470
|
+
* The true version of "nothing has changed", which depends on what was actually looked at.
|
|
471
|
+
*
|
|
472
|
+
* Reading only the working tree and finding it clean says nothing whatever about whether the
|
|
473
|
+
* product changed — the change may be sitting in a commit. Saying "nothing has changed" on
|
|
474
|
+
* the back of that was this file's own blind spot described in the tool's most confident
|
|
475
|
+
* voice, four times over, on a run that had just found four differences.
|
|
476
|
+
*
|
|
477
|
+
* @param {Changed} changed
|
|
478
|
+
* @returns {string}
|
|
479
|
+
*/
|
|
480
|
+
function nothingChanged(changed) {
|
|
481
|
+
return changed.committed
|
|
482
|
+
? 'Nothing has changed between the build you were happy with and this one — not in a commit and not in the working tree.'
|
|
483
|
+
: 'Nothing in the working tree has changed, and nothing here looked at what may already be committed.';
|
|
484
|
+
}
|
|
485
|
+
|
|
381
486
|
/**
|
|
382
487
|
* How far a finding sits from the nearest thing the agent edited.
|
|
383
488
|
*
|
|
@@ -418,10 +523,16 @@ function distanceFor(finding, distances, root, touches, known) {
|
|
|
418
523
|
|
|
419
524
|
for (const file of finding.nearFiles ?? []) look(file);
|
|
420
525
|
|
|
526
|
+
/** @type {{journey: string, files: number}[]} */
|
|
527
|
+
const tooBroad = [];
|
|
421
528
|
if (found.length === 0 && seenButUnreached.length === 0) {
|
|
422
529
|
for (const journey of journeysOf(finding)) {
|
|
423
530
|
const files = touches[journey];
|
|
424
|
-
if (!files || files.length === 0
|
|
531
|
+
if (!files || files.length === 0) continue;
|
|
532
|
+
if (files.length > MAX_JOURNEY_FILES) {
|
|
533
|
+
tooBroad.push({ journey, files: files.length });
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
425
536
|
for (const file of files) look(file);
|
|
426
537
|
}
|
|
427
538
|
}
|
|
@@ -429,6 +540,7 @@ function distanceFor(finding, distances, root, touches, known) {
|
|
|
429
540
|
// ceiling, and a list that is bounded today is bounded by a constant somebody may raise.
|
|
430
541
|
if (found.length > 0) return { distance: found.reduce((best, n) => (n < best ? n : best), found[0]), beyond: false };
|
|
431
542
|
if (seenButUnreached.length > 0) return { distance: null, beyond: true, beyondFile: seenButUnreached[0] };
|
|
543
|
+
if (tooBroad.length > 0) return { distance: null, beyond: false, tooBroad: tooBroad[0] };
|
|
432
544
|
return { distance: null, beyond: false };
|
|
433
545
|
}
|
|
434
546
|
|
|
@@ -455,6 +567,33 @@ function nearestFiles(finding, distances, root) {
|
|
|
455
567
|
// What the agent just changed
|
|
456
568
|
// ---------------------------------------------------------------------------
|
|
457
569
|
|
|
570
|
+
/**
|
|
571
|
+
* Everything this tool writes about your project, kept out of what your project IS.
|
|
572
|
+
*
|
|
573
|
+
* A build is told from another build, and an edit is told from the rest of the tree, by what
|
|
574
|
+
* git says is in the working tree: the diff, plus the list of files git does not know about.
|
|
575
|
+
* Stays Fixed's own folder is a file git does not know about, and it gains files on every
|
|
576
|
+
* single run. Left in, it is counted as something the agent just changed.
|
|
577
|
+
*
|
|
578
|
+
* That cost the fingerprint first, and it was the worse half: two runs on identical source
|
|
579
|
+
* were two different builds, so the second could never find the first one's record, a clean
|
|
580
|
+
* checkout was never clean, `--against HEAD` matched nothing, and the stored-record
|
|
581
|
+
* comparison the whole design rests on could not work at all. Measured on a scratch product:
|
|
582
|
+
* five runs, one unchanged source file, five different build ids.
|
|
583
|
+
*
|
|
584
|
+
* It cost the ranking too, and that half went unfixed until 2026-08-30. `whatChanged` reads
|
|
585
|
+
* the same two git calls to work out how far a finding sits from the edit, so the tool's own
|
|
586
|
+
* output was one of the places the walk started from — a clean tree never got the sentence
|
|
587
|
+
* saying it was clean, `youChanged` named `.staysfixed/...` back to the person as a file
|
|
588
|
+
* they had edited, and the causal proof copied the whole store into its scratch checkout and
|
|
589
|
+
* believed there was a change to undo when there was none.
|
|
590
|
+
*
|
|
591
|
+
* Excluded rather than gitignored, and that difference matters: gitignoring it would fix the
|
|
592
|
+
* fingerprint and would also throw away the observation files the design says to keep
|
|
593
|
+
* forever. What a project's own tooling wrote about a project is never part of the project.
|
|
594
|
+
*/
|
|
595
|
+
export const NOT_THE_TOOLS_OWN_FOLDER = ':(exclude,top).staysfixed';
|
|
596
|
+
|
|
458
597
|
/**
|
|
459
598
|
* Read the working tree's diff: which files, and which hunks inside them.
|
|
460
599
|
*
|
|
@@ -462,10 +601,23 @@ function nearestFiles(finding, distances, root) {
|
|
|
462
601
|
* checked - it just gets its findings ordered by kind rather than by distance,
|
|
463
602
|
* and it is told so.
|
|
464
603
|
*
|
|
604
|
+
* A CHANGE THAT HAS BEEN COMMITTED IS STILL A CHANGE. This used to read the working tree and
|
|
605
|
+
* nothing else, so the moment an agent committed its work — which is what an agent does at
|
|
606
|
+
* the end of a task, and the reference is a shipped build, which is by definition a commit —
|
|
607
|
+
* the diff came back empty, the distance measure went blind, the ranking lost its ordering,
|
|
608
|
+
* and every finding carried the sentence "nothing in the working tree has changed, so there
|
|
609
|
+
* is no edit to measure this against" while the tool sat there holding four differences it
|
|
610
|
+
* had just found. The change was neither missing nor unknowable: it is `git diff <the
|
|
611
|
+
* reference's commit>`, which covers what was committed and what is still uncommitted in one
|
|
612
|
+
* patch. `since` is that commit, and with it "nothing has changed" becomes a thing this can
|
|
613
|
+
* say truthfully instead of a thing it says whenever the tree happens to be clean.
|
|
614
|
+
*
|
|
465
615
|
* @param {string} cwd
|
|
616
|
+
* @param {{since?: string}} [opts] `since` is the reference build's commit. Without it, only
|
|
617
|
+
* the working tree is read, which is right for a caller that has no reference to name.
|
|
466
618
|
* @returns {Promise<Changed>}
|
|
467
619
|
*/
|
|
468
|
-
export async function whatChanged(cwd) {
|
|
620
|
+
export async function whatChanged(cwd, opts = {}) {
|
|
469
621
|
const root = await git(['rev-parse', '--show-toplevel'], cwd);
|
|
470
622
|
if (!root) {
|
|
471
623
|
return {
|
|
@@ -476,6 +628,8 @@ export async function whatChanged(cwd) {
|
|
|
476
628
|
hunks: [],
|
|
477
629
|
patch: '',
|
|
478
630
|
root: cwd,
|
|
631
|
+
base: 'HEAD',
|
|
632
|
+
committed: false,
|
|
479
633
|
};
|
|
480
634
|
}
|
|
481
635
|
const head = await git(['rev-parse', '--verify', 'HEAD'], cwd);
|
|
@@ -488,12 +642,38 @@ export async function whatChanged(cwd) {
|
|
|
488
642
|
hunks: [],
|
|
489
643
|
patch: '',
|
|
490
644
|
root,
|
|
645
|
+
base: 'HEAD',
|
|
646
|
+
committed: false,
|
|
491
647
|
};
|
|
492
648
|
}
|
|
493
649
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
650
|
+
// A commit that is not in this checkout is not a reason to give up — the working tree is
|
|
651
|
+
// still worth reading — but it IS a reason to say so, because everything measured after it
|
|
652
|
+
// is measured from somewhere other than where the caller asked.
|
|
653
|
+
let base = 'HEAD';
|
|
654
|
+
let baseWhy = '';
|
|
655
|
+
if (opts.since) {
|
|
656
|
+
const resolved = await git(['rev-parse', '--verify', `${opts.since}^{commit}`], root);
|
|
657
|
+
if (resolved) base = resolved;
|
|
658
|
+
else {
|
|
659
|
+
baseWhy = `The build you were happy with is at ${opts.since}, and that commit is not in this checkout, so your change was measured from the working tree alone. Anything you have already committed is invisible to the ordering below.`;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Asked from the repository root, not from wherever the caller was standing. `git diff`
|
|
664
|
+
// answers for the whole repository whatever folder it is run in; `git ls-files --others`
|
|
665
|
+
// does not — it lists only what is under the current folder, and it names those files
|
|
666
|
+
// relative to it. So a check run from a subfolder used to hand back new files under a path
|
|
667
|
+
// that resolved against the root to somewhere that does not exist, and never mentioned a
|
|
668
|
+
// new file anywhere else in the repository at all. The distance measure then started from
|
|
669
|
+
// a file that is not there, and the causal proof quietly declined to carry the real one
|
|
670
|
+
// into its scratch checkout because it "could not find" it.
|
|
671
|
+
// One argument, not a range. `git diff <commit>` is that commit's tree against the WORKING
|
|
672
|
+
// TREE, so committed work and uncommitted work arrive together in one patch that applies
|
|
673
|
+
// cleanly to a checkout of that commit — which is exactly what the causal proof needs.
|
|
674
|
+
const diff = await gitTry(['diff', base, '-U3', '--no-color', '--no-ext-diff', '--', NOT_THE_TOOLS_OWN_FOLDER], root);
|
|
675
|
+
const names = await gitTry(['diff', base, '--name-only', '--', NOT_THE_TOOLS_OWN_FOLDER], root);
|
|
676
|
+
const others = await gitTry(['ls-files', '--others', '--exclude-standard', '--', NOT_THE_TOOLS_OWN_FOLDER], root);
|
|
497
677
|
|
|
498
678
|
// Not knowing WHICH files changed is a different and worse failure than not being able to
|
|
499
679
|
// read the diff of them, so it is reported as not knowing anything rather than as an empty
|
|
@@ -508,6 +688,8 @@ export async function whatChanged(cwd) {
|
|
|
508
688
|
hunks: [],
|
|
509
689
|
patch: '',
|
|
510
690
|
root,
|
|
691
|
+
base,
|
|
692
|
+
committed: base !== 'HEAD',
|
|
511
693
|
};
|
|
512
694
|
}
|
|
513
695
|
|
|
@@ -519,7 +701,10 @@ export async function whatChanged(cwd) {
|
|
|
519
701
|
hunks: diff.ok ? parseHunks(diff.text) : [],
|
|
520
702
|
patch: diff.ok ? diff.text : '',
|
|
521
703
|
root,
|
|
704
|
+
base,
|
|
705
|
+
committed: base !== 'HEAD',
|
|
522
706
|
};
|
|
707
|
+
if (baseWhy) changed.baseWhy = baseWhy;
|
|
523
708
|
if (!diff.ok) {
|
|
524
709
|
// A diff that is too big for the buffer, or a git that took too long, used to come back
|
|
525
710
|
// as the empty string — which every reader downstream read as "the working tree is
|
|
@@ -616,7 +801,7 @@ export function parseHunks(patch) {
|
|
|
616
801
|
*
|
|
617
802
|
* @param {string} root
|
|
618
803
|
* @param {{maxFiles?: number}} [opts]
|
|
619
|
-
* @returns {Promise<{neighbours: Map<string, Set<string>>, files: string[], truncated: boolean, tooBig: string[], unreadable: string[]}>}
|
|
804
|
+
* @returns {Promise<{neighbours: Map<string, Set<string>>, files: string[], truncated: boolean, tooBig: string[], unreadable: string[], unreadableDirs: string[]}>}
|
|
620
805
|
*/
|
|
621
806
|
export async function importGraph(root, opts = {}) {
|
|
622
807
|
const limit = opts.maxFiles ?? MAX_FILES;
|
|
@@ -665,7 +850,7 @@ export async function importGraph(root, opts = {}) {
|
|
|
665
850
|
}
|
|
666
851
|
}
|
|
667
852
|
|
|
668
|
-
return { neighbours, files: files.list, truncated: files.truncated, tooBig, unreadable };
|
|
853
|
+
return { neighbours, files: files.list, truncated: files.truncated, tooBig, unreadable, unreadableDirs: files.unreadableDirs };
|
|
669
854
|
}
|
|
670
855
|
|
|
671
856
|
/**
|
|
@@ -707,13 +892,15 @@ export function distancesFrom(neighbours, seeds, maxHops) {
|
|
|
707
892
|
*
|
|
708
893
|
* @param {string} root
|
|
709
894
|
* @param {number} limit
|
|
710
|
-
* @returns {Promise<{list: string[], truncated: boolean}>}
|
|
895
|
+
* @returns {Promise<{list: string[], truncated: boolean, unreadableDirs: string[]}>}
|
|
711
896
|
*/
|
|
712
897
|
async function sourceFiles(root, limit) {
|
|
713
898
|
/** @type {string[]} */
|
|
714
899
|
const list = [];
|
|
715
900
|
/** @type {string[]} */
|
|
716
901
|
const queue = [root];
|
|
902
|
+
/** @type {string[]} */
|
|
903
|
+
const unreadableDirs = [];
|
|
717
904
|
let truncated = false;
|
|
718
905
|
while (queue.length > 0) {
|
|
719
906
|
const dir = queue.pop();
|
|
@@ -723,6 +910,12 @@ async function sourceFiles(root, limit) {
|
|
|
723
910
|
try {
|
|
724
911
|
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
725
912
|
} catch {
|
|
913
|
+
// A folder that will not open is every source file inside it, gone from the graph
|
|
914
|
+
// without a word — and this one was worse than the file-level version above it,
|
|
915
|
+
// because a folder is not one file, it is a whole branch of the product. Everything
|
|
916
|
+
// under it then looks like code nothing imports, so a side effect in it is either
|
|
917
|
+
// ranked as "we have no idea where this came from" or missed by the graph entirely.
|
|
918
|
+
unreadableDirs.push(path.relative(root, dir) || dir);
|
|
726
919
|
continue;
|
|
727
920
|
}
|
|
728
921
|
for (const entry of entries) {
|
|
@@ -737,12 +930,12 @@ async function sourceFiles(root, limit) {
|
|
|
737
930
|
if (!SOURCE_EXTENSIONS.includes(path.extname(entry.name))) continue;
|
|
738
931
|
if (list.length >= limit) {
|
|
739
932
|
truncated = true;
|
|
740
|
-
return { list, truncated };
|
|
933
|
+
return { list, truncated, unreadableDirs };
|
|
741
934
|
}
|
|
742
935
|
list.push(path.join(dir, entry.name));
|
|
743
936
|
}
|
|
744
937
|
}
|
|
745
|
-
return { list, truncated };
|
|
938
|
+
return { list, truncated, unreadableDirs };
|
|
746
939
|
}
|
|
747
940
|
|
|
748
941
|
/**
|
package/src/v2/reference.js
CHANGED
|
@@ -308,12 +308,16 @@ export async function measureStability(store, buildId) {
|
|
|
308
308
|
let measuredJourneys = 0;
|
|
309
309
|
|
|
310
310
|
for (const journey of journeys) {
|
|
311
|
-
const
|
|
311
|
+
const looked = await twoRunsOf(store, buildId, journey);
|
|
312
|
+
const pair = looked.pair;
|
|
312
313
|
if (!pair) {
|
|
313
314
|
byJourney.push({
|
|
314
315
|
journey,
|
|
315
316
|
measured: false,
|
|
316
|
-
why:
|
|
317
|
+
why:
|
|
318
|
+
looked.unreadable > 0
|
|
319
|
+
? `${looked.unreadable} of the ${looked.stored} stored runs of this journey could not be read, so the pair needed to measure how steady it was is not there. This build DID walk it more than once — the evidence is on the disk and it is damaged, which is a different problem from never having walked it.`
|
|
320
|
+
: 'This build only ever walked this journey once, so nothing here says how steady it was.',
|
|
317
321
|
paths: 0,
|
|
318
322
|
steady: 0,
|
|
319
323
|
unstableCount: 0,
|
|
@@ -412,36 +416,47 @@ function stabilityNote(measured, journeys, measuredJourneys, steady, unstable) {
|
|
|
412
416
|
* would measure the difference between two afternoons and call it wobble. So: take the
|
|
413
417
|
* newest second run, then the newest first run that came before it.
|
|
414
418
|
*
|
|
419
|
+
* WHY IT SAYS HOW MANY IT COULD NOT READ. One unreadable capture must never take the whole
|
|
420
|
+
* stability record with it, and it never did — but it used to vanish into a `continue`, and
|
|
421
|
+
* a journey that lost its pair that way came back as the same plain `null` a journey that
|
|
422
|
+
* was genuinely only ever walked once comes back as. The caller then wrote "this build only
|
|
423
|
+
* ever walked this journey once, so nothing here says how steady it was" onto the reference,
|
|
424
|
+
* for good, about a journey that was walked twice and whose evidence is sitting on the disk
|
|
425
|
+
* damaged. That sentence sends somebody to walk it again; the truth would have sent them to
|
|
426
|
+
* look at their store.
|
|
427
|
+
*
|
|
415
428
|
* @param {Store} store
|
|
416
429
|
* @param {string} buildId
|
|
417
430
|
* @param {string} journey
|
|
418
|
-
* @returns {Promise<{a: Capture, b: Capture}|null>}
|
|
431
|
+
* @returns {Promise<{pair: {a: Capture, b: Capture}|null, stored: number, unreadable: number}>}
|
|
419
432
|
*/
|
|
420
433
|
async function twoRunsOf(store, buildId, journey) {
|
|
421
434
|
const refs = await listCaptures(store, { buildId, journey });
|
|
422
|
-
if (refs.length < 2) return null;
|
|
435
|
+
if (refs.length < 2) return { pair: null, stored: refs.length, unreadable: 0 };
|
|
423
436
|
|
|
424
437
|
/** @type {Capture[]} */
|
|
425
438
|
const captures = [];
|
|
439
|
+
let unreadable = 0;
|
|
426
440
|
for (const ref of refs) {
|
|
427
441
|
/** @type {Capture|null} */
|
|
428
442
|
let capture = null;
|
|
429
443
|
try {
|
|
430
444
|
capture = await loadCapture(store, ref);
|
|
431
445
|
} catch {
|
|
432
|
-
|
|
446
|
+
unreadable += 1;
|
|
433
447
|
continue;
|
|
434
448
|
}
|
|
435
449
|
if (capture) captures.push(capture);
|
|
450
|
+
else unreadable += 1;
|
|
436
451
|
}
|
|
437
452
|
|
|
438
453
|
for (let i = captures.length - 1; i >= 0; i--) {
|
|
439
454
|
if (captures[i].run !== 'b') continue;
|
|
440
455
|
for (let j = i - 1; j >= 0; j--) {
|
|
441
|
-
if (captures[j].run === 'a') return { a: captures[j], b: captures[i] };
|
|
456
|
+
if (captures[j].run === 'a') return { pair: { a: captures[j], b: captures[i] }, stored: refs.length, unreadable };
|
|
442
457
|
}
|
|
443
458
|
}
|
|
444
|
-
return null;
|
|
459
|
+
return { pair: null, stored: refs.length, unreadable };
|
|
445
460
|
}
|
|
446
461
|
|
|
447
462
|
// ---------------------------------------------------------------------------
|
|
@@ -1032,11 +1047,26 @@ export async function currentReference(store, product) {
|
|
|
1032
1047
|
* The ship hook uses this to work out which product it is looking at when nobody said, and
|
|
1033
1048
|
* a `doctor` or summary uses it to name the products that are not being checked at all.
|
|
1034
1049
|
*
|
|
1050
|
+
* BOTH OF THOSE USES ARE RUINED BY A LIST THAT IS QUIETLY SHORT. A build record that cannot
|
|
1051
|
+
* be read is a build that is not in the list, and a product whose only builds are damaged
|
|
1052
|
+
* records is a product that is not in the list at all — so the ship hook sees one product
|
|
1053
|
+
* where there are two and blesses the wrong one without a word, and the summary says a
|
|
1054
|
+
* product is not being checked when it is. Until 2026-08-30 this asked for the builds without
|
|
1055
|
+
* asking to be told about the ones that were skipped, so neither could have known.
|
|
1056
|
+
*
|
|
1057
|
+
* The problems come back BESIDE the list rather than through a callback nobody has to pass,
|
|
1058
|
+
* because a caller that does not want to hear them now has to say so on purpose.
|
|
1059
|
+
*
|
|
1035
1060
|
* @param {Store} store
|
|
1036
|
-
* @returns {Promise<{product: string, hasReference: boolean, builds: number}[]>}
|
|
1061
|
+
* @returns {Promise<{products: {product: string, hasReference: boolean, builds: number}[], problems: string[]}>}
|
|
1062
|
+
* `problems` is empty when every build folder could be read. Each entry is a plain
|
|
1063
|
+
* sentence naming a folder that could not be, and anything built on this list is weaker
|
|
1064
|
+
* for as long as one is there.
|
|
1037
1065
|
*/
|
|
1038
1066
|
export async function productsKnown(store) {
|
|
1039
|
-
|
|
1067
|
+
/** @type {string[]} */
|
|
1068
|
+
const problems = [];
|
|
1069
|
+
const builds = await listBuilds(store, { onProblem: (message) => problems.push(message) });
|
|
1040
1070
|
/** @type {Map<string, {product: string, hasReference: boolean, builds: number}>} */
|
|
1041
1071
|
const seen = new Map();
|
|
1042
1072
|
for (const record of builds) {
|
|
@@ -1047,5 +1077,5 @@ export async function productsKnown(store) {
|
|
|
1047
1077
|
if (record.isReference) entry.hasReference = true;
|
|
1048
1078
|
seen.set(product, entry);
|
|
1049
1079
|
}
|
|
1050
|
-
return [...seen.values()].sort((a, b) => a.product.localeCompare(b.product));
|
|
1080
|
+
return { products: [...seen.values()].sort((a, b) => a.product.localeCompare(b.product)), problems };
|
|
1051
1081
|
}
|