staysfixed 0.6.2 → 0.7.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/src/v2/rank.js CHANGED
@@ -110,6 +110,20 @@ const CHANNEL_WEIGHT = {
110
110
  pixels: 1,
111
111
  };
112
112
 
113
+ /**
114
+ * How far a finding sits from the edit, and — when the answer is "we do not know" — WHICH
115
+ * kind of not knowing it is. Three different states used to arrive as one `null`:
116
+ * nothing names the finding's source, the file is known and the edit never reaches it, and
117
+ * the graph was never built. Only the middle one is a side effect, and it is the loudest
118
+ * signal this tool has.
119
+ *
120
+ * @typedef {object} HowFar
121
+ * @property {number|null} distance Hops from the nearest changed file, or null.
122
+ * @property {boolean} beyond The source file is in this project and no path of at
123
+ * most `maxHops` imports leads to it from anything changed.
124
+ * @property {string} [beyondFile] Which file, so the sentence can name it.
125
+ */
126
+
113
127
  /**
114
128
  * @typedef {object} ChangedHunk
115
129
  * @property {string} file Repo-relative, the "after" side.
@@ -130,6 +144,13 @@ const CHANNEL_WEIGHT = {
130
144
  * @property {ChangedHunk[]} hunks
131
145
  * @property {string} patch The whole working diff, as one patch.
132
146
  * @property {string} root Absolute repo root.
147
+ * @property {boolean} [patchUnread] True when git could not hand the working diff over at
148
+ * all — it timed out, or the diff was larger than the
149
+ * buffer set aside for it. `hunks` and `patch` are then
150
+ * empty for a reason that is NOT "nothing changed", and
151
+ * everything downstream has to be told which of the two
152
+ * it is looking at.
153
+ * @property {string} [patchUnreadWhy]
133
154
  */
134
155
 
135
156
  /**
@@ -157,31 +178,51 @@ export async function rankFindings(findings, opts) {
157
178
  );
158
179
  }
159
180
 
181
+ if (changed.patchUnread === true) {
182
+ notes.push(
183
+ `${changed.patchUnreadWhy ?? 'git could not hand over the working diff.'} So the list of files you changed is being used, but the individual changes inside them are not known here — and nothing can be proved by undoing one of them until git can hand the diff over.`,
184
+ );
185
+ }
186
+
187
+ const hops = opts.maxHops ?? MAX_HOPS;
160
188
  const seeds = [...changed.files, ...changed.untracked].map((f) => path.resolve(changed.root, f));
161
189
  /** @type {Map<string, number>} */
162
190
  let distances = new Map();
191
+ /** @type {Set<string>} */
192
+ let known = new Set();
163
193
  if (seeds.length > 0) {
164
194
  const graph = await importGraph(changed.root);
195
+ known = new Set(graph.files);
165
196
  if (graph.truncated) {
166
197
  notes.push(
167
- 'This project has more source files than the distance measure will walk, so some findings say their distance is unknown.',
198
+ `This project has more than ${MAX_FILES} source files, which is as many as the distance measure will walk, so some findings say their distance is unknown.`,
199
+ );
200
+ }
201
+ if (graph.tooBig.length > 0) {
202
+ notes.push(
203
+ `${graph.tooBig.length} source ${graph.tooBig.length === 1 ? 'file was' : 'files were'} too large to read for the distance measure (over ${Math.round(MAX_FILE_BYTES / 1000)}KB): ${graph.tooBig.slice(0, 3).join(', ')}${graph.tooBig.length > 3 ? ', and others' : ''}. Anything only they import looks unconnected to your edit, so it may be ranked lower than it deserves.`,
204
+ );
205
+ }
206
+ if (graph.unreadable.length > 0) {
207
+ notes.push(
208
+ `${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.`,
168
209
  );
169
210
  }
170
- distances = distancesFrom(graph.neighbours, seeds, opts.maxHops ?? MAX_HOPS);
171
- } else if (changed.ok) {
211
+ distances = distancesFrom(graph.neighbours, seeds, hops);
212
+ } else if (changed.ok && changed.patchUnread !== true) {
172
213
  notes.push('Nothing in the working tree has changed, so none of this can be blamed on an edit you just made.');
173
214
  }
174
215
 
175
216
  const ranked = findings.map((finding) => {
176
217
  const sealedClass = classOf(finding, guards);
177
- const distance = distanceFor(finding, distances, changed.root, opts.touches ?? {});
218
+ const how = distanceFor(finding, distances, changed.root, opts.touches ?? {}, known);
178
219
  /** @type {Finding} */
179
220
  const out = {
180
221
  ...finding,
181
222
  class: sealedClass,
182
223
  sealed: sealedClass !== 'ordinary',
183
- rank: scoreOf(finding, sealedClass, distance),
184
- why: explain(finding, sealedClass, distance, seeds.length > 0),
224
+ rank: scoreOf(finding, sealedClass, how),
225
+ why: explain(finding, sealedClass, how, seeds.length > 0, hops),
185
226
  };
186
227
  const near = nearestFiles(finding, distances, changed.root);
187
228
  if (near.length > 0) out.nearFiles = near;
@@ -216,33 +257,78 @@ export function classOf(finding, guards) {
216
257
  ...journeysOf(finding),
217
258
  ].join(' \n ');
218
259
 
260
+ // The VALUES as well, and this is the half that was missing.
261
+ //
262
+ // The doc above this function has always said the words are matched against everything the
263
+ // finding says about itself. They were not: only its addresses and the sentences written
264
+ // about it. A finding's title carries at most the first seventy characters of the value, so
265
+ // a stack trace whose "fatal" is on line four, or a request body whose "currency" comes
266
+ // after a long url, said nothing at all — and a crash, a charge or a sign-in that changed
267
+ // was filed `ordinary`, which is precisely the class an agent is allowed to wave through
268
+ // on its own. Every one of the five sealed classes was blind in the same place.
269
+ //
270
+ // Nothing is truncated here and nothing is sampled. A cap would put the blindness back in a
271
+ // new place, and the cost is one pass over values that have already been normalised, stored
272
+ // and diffed several times over.
273
+ /** @type {string[]} */
274
+ const values = [];
275
+ for (const d of finding.differences) {
276
+ if (d.reference !== undefined) values.push(asText(d.reference));
277
+ if (d.candidate !== undefined) values.push(asText(d.candidate));
278
+ }
279
+ /** @param {RegExp} rx */
280
+ const says = (rx) => rx.test(haystack) || values.some((v) => rx.test(v));
281
+
219
282
  const channels = new Set(finding.differences.map((d) => d.channel));
220
283
  for (const name of guards) {
221
- if (name && haystack.toLowerCase().includes(name.toLowerCase())) return 'guard';
284
+ if (!name) continue;
285
+ const wanted = name.toLowerCase();
286
+ if (haystack.toLowerCase().includes(wanted)) return 'guard';
287
+ if (values.some((v) => v.toLowerCase().includes(wanted))) return 'guard';
222
288
  }
223
289
  if (finding.differences.some((d) => splitPath(d.path)[0] === 'guard')) return 'guard';
224
- if (channels.has('complaints') && CRASH.test(haystack)) return 'crash';
225
- if (DATA_LOSS_ALWAYS.test(haystack)) return 'data-loss';
290
+ if (channels.has('complaints') && says(CRASH)) return 'crash';
291
+ if (says(DATA_LOSS_ALWAYS)) return 'data-loss';
226
292
  // The softer words — "delete", "migrate" — only seal when something actually
227
293
  // went out or came back. A button labelled Delete that changed colour is not a
228
294
  // data-loss incident, and treating it as one is how a safety net gets ignored.
229
295
  if (
230
- DATA_LOSS_IN_EFFECTS.test(haystack) &&
296
+ says(DATA_LOSS_IN_EFFECTS) &&
231
297
  (channels.has('effects') || channels.has('results') || channels.has('contract'))
232
298
  ) {
233
299
  return 'data-loss';
234
300
  }
235
- if (MONEY.test(haystack)) return 'money';
236
- if (SIGN_IN.test(haystack)) return 'sign-in';
301
+ if (says(MONEY)) return 'money';
302
+ if (says(SIGN_IN)) return 'sign-in';
237
303
  return 'ordinary';
238
304
  }
239
305
 
306
+ /**
307
+ * One observed value as searchable text.
308
+ *
309
+ * A string is itself; anything else is its JSON, so a word inside a nested body is found the
310
+ * same way as a word in a log line. A value that cannot be turned into JSON — which nothing
311
+ * that reached here should be, since observations are validated at birth — comes back as the
312
+ * empty string rather than taking the run down over a sealing check.
313
+ *
314
+ * @param {unknown} value
315
+ * @returns {string}
316
+ */
317
+ function asText(value) {
318
+ if (typeof value === 'string') return value;
319
+ try {
320
+ return JSON.stringify(value) ?? '';
321
+ } catch {
322
+ return '';
323
+ }
324
+ }
325
+
240
326
  /**
241
327
  * @param {Finding} finding
242
328
  * @param {FindingClass} sealedClass
243
- * @param {number|null} distance
329
+ * @param {HowFar} how
244
330
  */
245
- function scoreOf(finding, sealedClass, distance) {
331
+ function scoreOf(finding, sealedClass, how) {
246
332
  if (sealedClass !== 'ordinary') {
247
333
  // Sealed findings live above everything else, ordered among themselves by
248
334
  // how much damage the class can do. The gap is deliberately enormous, so no
@@ -252,8 +338,14 @@ function scoreOf(finding, sealedClass, distance) {
252
338
  }
253
339
 
254
340
  // Far from the edit is suspicious. Inside the edit is expected, and sorts last.
255
- const far = distance === null ? 3 : distance === 0 ? 0 : Math.min(2 + distance * 2, 12);
256
- const channel = Math.max(...finding.differences.map((d) => CHANNEL_WEIGHT[d.channel] ?? 2));
341
+ // `beyond` is farther than the measure walks the MOST suspicious thing there is and it
342
+ // used to be filed as "unknown" and scored in the middle, alongside a finding whose source
343
+ // file nobody could name. Those are opposite answers.
344
+ const far = how.beyond ? 14 : how.distance === null ? 3 : how.distance === 0 ? 0 : Math.min(2 + how.distance * 2, 12);
345
+ // A reduce, not `Math.max(...list)`. Spreading an array into a call blows the stack at
346
+ // somewhere over a hundred thousand items, and a cluster that big is not hypothetical on a
347
+ // product whose reference holds fifteen thousand addresses across seventeen journeys.
348
+ const channel = finding.differences.reduce((best, d) => Math.max(best, CHANNEL_WEIGHT[d.channel] ?? 2), 0);
257
349
  // Something appearing or vanishing is worth more than something moving: a
258
350
  // whole address arriving or leaving is a shape change, not a value change.
259
351
  const shape = finding.differences.some((d) => d.kind !== 'changed') ? 4 : 0;
@@ -264,22 +356,26 @@ function scoreOf(finding, sealedClass, distance) {
264
356
  /**
265
357
  * @param {Finding} finding
266
358
  * @param {FindingClass} sealedClass
267
- * @param {number|null} distance
359
+ * @param {HowFar} how
268
360
  * @param {boolean} knewWhatChanged
361
+ * @param {number} hops How far out the measure walked before it stopped.
269
362
  */
270
- function explain(finding, sealedClass, distance, knewWhatChanged) {
363
+ function explain(finding, sealedClass, how, knewWhatChanged, hops) {
271
364
  if (sealedClass !== 'ordinary') {
272
365
  return `Nobody may wave this through on their own: it touches ${SEAL_WORDS[sealedClass]}. It goes to a person whatever caused it.`;
273
366
  }
274
367
  if (!knewWhatChanged) {
275
368
  return 'Nothing in the working tree has changed, so there is no edit to measure this against.';
276
369
  }
277
- if (distance === null) {
370
+ if (how.beyond) {
371
+ 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
+ }
373
+ if (how.distance === null) {
278
374
  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.';
279
375
  }
280
- if (distance === 0) return 'This is in a file you just changed, so it is most likely what you meant to do.';
281
- if (distance === 1) return 'This is one step away from a file you changed, so your edit probably reaches it.';
282
- return `This is ${distance} steps away from anything you changed. That is what a side effect looks like.`;
376
+ if (how.distance === 0) return 'This is in a file you just changed, so it is most likely what you meant to do.';
377
+ if (how.distance === 1) return 'This is one step away from a file you changed, so your edit probably reaches it.';
378
+ return `This is ${how.distance} steps away from anything you changed. That is what a side effect looks like.`;
283
379
  }
284
380
 
285
381
  /**
@@ -294,12 +390,17 @@ function explain(finding, sealedClass, distance, knewWhatChanged) {
294
390
  * @param {Map<string, number>} distances
295
391
  * @param {string} root
296
392
  * @param {Record<string, string[]>} touches
297
- * @returns {number|null}
393
+ * @param {Set<string>} known Every source file the graph walked, so "we never heard of
394
+ * this file" can be told from "we heard of it and your edit
395
+ * does not reach it".
396
+ * @returns {HowFar}
298
397
  */
299
- function distanceFor(finding, distances, root, touches) {
300
- if (distances.size === 0) return null;
398
+ function distanceFor(finding, distances, root, touches, known) {
399
+ if (distances.size === 0) return { distance: null, beyond: false };
301
400
  /** @type {number[]} */
302
401
  const found = [];
402
+ /** @type {string[]} */
403
+ const seenButUnreached = [];
303
404
 
304
405
  /** @param {string|undefined} file */
305
406
  const look = (file) => {
@@ -307,18 +408,28 @@ function distanceFor(finding, distances, root, touches) {
307
408
  const abs = path.isAbsolute(file) ? file : path.resolve(root, file);
308
409
  const hops = distances.get(abs);
309
410
  if (typeof hops === 'number') found.push(hops);
411
+ // The file IS in the project and the walk out from the edit never arrived at it. That is
412
+ // the farthest a finding can be, and until 2026-08-30 it was scored and worded exactly
413
+ // like "we have no idea where this came from" — the mid-table answer. So the single most
414
+ // suspicious finding this tool can produce sorted below one whose source was simply
415
+ // unknown, and the sentence beside it said nothing named the code, which was untrue.
416
+ else if (known.has(abs)) seenButUnreached.push(path.relative(root, abs) || abs);
310
417
  };
311
418
 
312
419
  for (const file of finding.nearFiles ?? []) look(file);
313
420
 
314
- if (found.length === 0) {
421
+ if (found.length === 0 && seenButUnreached.length === 0) {
315
422
  for (const journey of journeysOf(finding)) {
316
423
  const files = touches[journey];
317
424
  if (!files || files.length === 0 || files.length > 25) continue;
318
425
  for (const file of files) look(file);
319
426
  }
320
427
  }
321
- return found.length > 0 ? Math.min(...found) : null;
428
+ // A reduce for the same reason `scoreOf` uses one: spreading a list into a call has a
429
+ // ceiling, and a list that is bounded today is bounded by a constant somebody may raise.
430
+ if (found.length > 0) return { distance: found.reduce((best, n) => (n < best ? n : best), found[0]), beyond: false };
431
+ if (seenButUnreached.length > 0) return { distance: null, beyond: true, beyondFile: seenButUnreached[0] };
432
+ return { distance: null, beyond: false };
322
433
  }
323
434
 
324
435
  /**
@@ -380,18 +491,44 @@ export async function whatChanged(cwd) {
380
491
  };
381
492
  }
382
493
 
383
- const patch = (await git(['diff', 'HEAD', '-U3', '--no-color', '--no-ext-diff'], cwd, true)) ?? '';
384
- const names = (await git(['diff', 'HEAD', '--name-only'], cwd)) ?? '';
385
- const others = (await git(['ls-files', '--others', '--exclude-standard'], cwd)) ?? '';
494
+ const diff = await gitTry(['diff', 'HEAD', '-U3', '--no-color', '--no-ext-diff'], cwd);
495
+ const names = await gitTry(['diff', 'HEAD', '--name-only'], cwd);
496
+ const others = await gitTry(['ls-files', '--others', '--exclude-standard'], cwd);
386
497
 
387
- return {
498
+ // Not knowing WHICH files changed is a different and worse failure than not being able to
499
+ // read the diff of them, so it is reported as not knowing anything rather than as an empty
500
+ // list — an empty list reads as "you changed nothing", and ranking would then quietly stop
501
+ // measuring distance while saying it had.
502
+ if (!names.ok) {
503
+ return {
504
+ ok: false,
505
+ why: `git could not say which files you have changed: ${names.why}`,
506
+ files: [],
507
+ untracked: lines(others.text),
508
+ hunks: [],
509
+ patch: '',
510
+ root,
511
+ };
512
+ }
513
+
514
+ /** @type {Changed} */
515
+ const changed = {
388
516
  ok: true,
389
- files: lines(names),
390
- untracked: lines(others),
391
- hunks: parseHunks(patch),
392
- patch,
517
+ files: lines(names.text),
518
+ untracked: lines(others.text),
519
+ hunks: diff.ok ? parseHunks(diff.text) : [],
520
+ patch: diff.ok ? diff.text : '',
393
521
  root,
394
522
  };
523
+ if (!diff.ok) {
524
+ // A diff that is too big for the buffer, or a git that took too long, used to come back
525
+ // as the empty string — which every reader downstream read as "the working tree is
526
+ // clean". The causal proof then answered "nothing has changed, so there is nothing to
527
+ // undo" on a tree with a hundred edits in it, and sounded certain doing it.
528
+ changed.patchUnread = true;
529
+ changed.patchUnreadWhy = `git could not hand over the working diff: ${diff.why}`;
530
+ }
531
+ return changed;
395
532
  }
396
533
 
397
534
  /**
@@ -479,7 +616,7 @@ export function parseHunks(patch) {
479
616
  *
480
617
  * @param {string} root
481
618
  * @param {{maxFiles?: number}} [opts]
482
- * @returns {Promise<{neighbours: Map<string, Set<string>>, files: string[], truncated: boolean}>}
619
+ * @returns {Promise<{neighbours: Map<string, Set<string>>, files: string[], truncated: boolean, tooBig: string[], unreadable: string[]}>}
483
620
  */
484
621
  export async function importGraph(root, opts = {}) {
485
622
  const limit = opts.maxFiles ?? MAX_FILES;
@@ -487,6 +624,15 @@ export async function importGraph(root, opts = {}) {
487
624
  /** @type {Map<string, Set<string>>} */
488
625
  const neighbours = new Map();
489
626
  const known = new Set(files.list);
627
+ // A file skipped for being large, and a file skipped because it would not open, used to be
628
+ // the same silent `continue`. That is the exact shape that already cost this project once:
629
+ // the source reader stepped over a 3.5MB bundle and then reported it had found no source.
630
+ // Here it does not fake a finding, it warps the ranking — every module that only that file
631
+ // imports looks unconnected, so a side effect in it sorts as if nothing reached it.
632
+ /** @type {string[]} */
633
+ const tooBig = [];
634
+ /** @type {string[]} */
635
+ const unreadable = [];
490
636
 
491
637
  /** @param {string} a @param {string} b */
492
638
  const join = (a, b) => {
@@ -499,9 +645,13 @@ export async function importGraph(root, opts = {}) {
499
645
  let text = '';
500
646
  try {
501
647
  const stat = await fsp.stat(file);
502
- if (stat.size > MAX_FILE_BYTES) continue;
648
+ if (stat.size > MAX_FILE_BYTES) {
649
+ tooBig.push(path.relative(root, file));
650
+ continue;
651
+ }
503
652
  text = await fsp.readFile(file, 'utf8');
504
653
  } catch {
654
+ unreadable.push(path.relative(root, file));
505
655
  continue;
506
656
  }
507
657
  for (const spec of specifiersIn(text)) {
@@ -515,7 +665,7 @@ export async function importGraph(root, opts = {}) {
515
665
  }
516
666
  }
517
667
 
518
- return { neighbours, files: files.list, truncated: files.truncated };
668
+ return { neighbours, files: files.list, truncated: files.truncated, tooBig, unreadable };
519
669
  }
520
670
 
521
671
  /**
@@ -651,15 +801,34 @@ function resolveNearby(from, spec, known) {
651
801
  /**
652
802
  * @param {string[]} args
653
803
  * @param {string} cwd
654
- * @param {boolean} [keepBlankLines]
655
804
  * @returns {Promise<string|null>}
656
805
  */
657
- async function git(args, cwd, keepBlankLines = false) {
806
+ async function git(args, cwd) {
807
+ const said = await gitTry(args, cwd);
808
+ return said.ok ? said.text.trim() : null;
809
+ }
810
+
811
+ /**
812
+ * git, with the reason it did not work kept rather than thrown away.
813
+ *
814
+ * `git(...)` collapses every failure into `null`, and for "is this a repository at all" that
815
+ * is the right shape. For reading the diff it is not: an empty answer and a failed answer
816
+ * mean opposite things and only one of them means "nothing changed".
817
+ *
818
+ * @param {string[]} args
819
+ * @param {string} cwd
820
+ * @returns {Promise<{ok: boolean, text: string, why: string}>}
821
+ */
822
+ async function gitTry(args, cwd) {
658
823
  try {
659
824
  const { stdout } = await run('git', args, { cwd, timeout: 20_000, maxBuffer: 64 * 1024 * 1024 });
660
- return keepBlankLines ? stdout : stdout.trim();
661
- } catch {
662
- return null;
825
+ return { ok: true, text: stdout, why: '' };
826
+ } catch (e) {
827
+ const err = /** @type {{stderr?: string, message?: string, killed?: boolean, code?: string}} */ (e);
828
+ const why = err.killed
829
+ ? 'it took longer than twenty seconds'
830
+ : String(err.stderr || err.message || 'it failed').trim().split('\n')[0];
831
+ return { ok: false, text: '', why };
663
832
  }
664
833
  }
665
834