staysfixed 0.3.0 → 0.4.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.
Files changed (47) hide show
  1. package/README.md +534 -402
  2. package/package.json +8 -3
  3. package/src/cli/index.js +14 -0
  4. package/src/v2/adapters/android-driver.js +1705 -0
  5. package/src/v2/adapters/android.js +1117 -0
  6. package/src/v2/adapters/contract.js +565 -0
  7. package/src/v2/adapters/electron.js +1594 -0
  8. package/src/v2/adapters/http.js +733 -0
  9. package/src/v2/adapters/ios-driver.js +1551 -0
  10. package/src/v2/adapters/ios.js +989 -0
  11. package/src/v2/adapters/isolate.js +739 -0
  12. package/src/v2/adapters/process.js +920 -0
  13. package/src/v2/adapters/source.js +1241 -0
  14. package/src/v2/adapters/web-driver.js +1532 -0
  15. package/src/v2/adapters/web.js +1009 -0
  16. package/src/v2/adapters/windows.js +1329 -0
  17. package/src/v2/browsers.js +1203 -0
  18. package/src/v2/cause.js +364 -0
  19. package/src/v2/check.js +1331 -0
  20. package/src/v2/ci.js +1209 -0
  21. package/src/v2/cli.js +657 -0
  22. package/src/v2/cluster.js +372 -0
  23. package/src/v2/coverage.js +1116 -0
  24. package/src/v2/detect.js +1199 -0
  25. package/src/v2/doctor.js +1690 -0
  26. package/src/v2/escalate.js +679 -0
  27. package/src/v2/init.js +1394 -0
  28. package/src/v2/intent.js +659 -0
  29. package/src/v2/journeys/from-routes.js +498 -0
  30. package/src/v2/journeys/from-suite.js +988 -0
  31. package/src/v2/journeys/index.js +651 -0
  32. package/src/v2/journeys/record.js +516 -0
  33. package/src/v2/mcp/server.js +374 -0
  34. package/src/v2/mcp/tools.js +1571 -0
  35. package/src/v2/normalise.js +783 -0
  36. package/src/v2/observation.js +877 -0
  37. package/src/v2/rank.js +672 -0
  38. package/src/v2/reference.js +1051 -0
  39. package/src/v2/remote.js +911 -0
  40. package/src/v2/run.js +964 -0
  41. package/src/v2/sealed.js +564 -0
  42. package/src/v2/selfcheck.js +564 -0
  43. package/src/v2/ship.js +684 -0
  44. package/src/v2/store.js +703 -0
  45. package/src/v2/types.js +503 -0
  46. package/src/v2/waiver.js +511 -0
  47. package/src/watch/panel.js +73 -44
package/src/v2/rank.js ADDED
@@ -0,0 +1,672 @@
1
+ /**
2
+ * The order is the product.
3
+ *
4
+ * An agent that has just edited three files does not need to be told that those
5
+ * three files behave differently — it made them behave differently on purpose.
6
+ * What it needs is the thing that changed somewhere it never touched, because
7
+ * that is the definition of a side effect and it is the only reason this tool
8
+ * exists. So findings are ordered by DISTANCE from the edit: a difference far
9
+ * away sorts to the top, and a difference inside the edit sorts to the bottom.
10
+ *
11
+ * Distance is measured by walking imports out from the files the working tree
12
+ * has changed. It is a cheap graph and a rough number, and rough is fine: the
13
+ * gap between "in the file you edited" and "six modules away" is enormous and
14
+ * easy to see, and nothing here depends on telling four hops from five.
15
+ *
16
+ * Above all of it sit the sealed classes. Money, signing in, losing data, a
17
+ * crash, or anything touching a bug already reported once: those go first
18
+ * whatever the distance says, and no agent may wave them through.
19
+ */
20
+
21
+ import fsp from 'node:fs/promises';
22
+ import path from 'node:path';
23
+ import { execFile } from 'node:child_process';
24
+ import { promisify } from 'node:util';
25
+
26
+ import { splitPath } from './observation.js';
27
+ import { journeysOf } from './cluster.js';
28
+
29
+ /** @typedef {import('./types.js').Finding} Finding */
30
+ /** @typedef {import('./types.js').FindingClass} FindingClass */
31
+ /** @typedef {import('./types.js').Channel} Channel */
32
+
33
+ const run = promisify(execFile);
34
+
35
+ /** Folders that are never anybody's source code. Skipping them is most of what makes the graph cheap. */
36
+ const NOT_SOURCE = new Set([
37
+ 'node_modules',
38
+ '.git',
39
+ 'dist',
40
+ 'build',
41
+ 'out',
42
+ 'coverage',
43
+ '.next',
44
+ '.nuxt',
45
+ '.turbo',
46
+ '.cache',
47
+ '.staysfixed',
48
+ 'vendor',
49
+ 'Pods',
50
+ 'DerivedData',
51
+ '__pycache__',
52
+ '.venv',
53
+ 'target',
54
+ ]);
55
+
56
+ const SOURCE_EXTENSIONS = ['.js', '.mjs', '.cjs', '.jsx', '.ts', '.tsx', '.mts', '.cts', '.vue', '.svelte'];
57
+
58
+ /** Ceilings, so a check on a very large repo never turns into a crawl of it. */
59
+ const MAX_FILES = 4000;
60
+ const MAX_FILE_BYTES = 400_000;
61
+ const MAX_HOPS = 8;
62
+
63
+ /**
64
+ * The classes nobody may wave through, worst first. A guard leads because a
65
+ * guard means a bug he already reported once has come back, and that is the
66
+ * fastest way there is to lose trust in a product.
67
+ *
68
+ * @type {FindingClass[]}
69
+ */
70
+ const SEALED_ORDER = ['guard', 'crash', 'data-loss', 'money', 'sign-in'];
71
+
72
+ /**
73
+ * What each sealed class is called when it has to be said in a sentence.
74
+ * @type {Record<FindingClass, string>}
75
+ */
76
+ const SEAL_WORDS = {
77
+ guard: 'a bug you already reported once',
78
+ crash: 'a crash',
79
+ 'data-loss': 'losing data',
80
+ money: 'money',
81
+ 'sign-in': 'signing in',
82
+ ordinary: 'nothing sealed',
83
+ };
84
+
85
+ const MONEY =
86
+ /\b(charge|charged|charging|payment|payments|invoice|refund|price|pricing|billing|billed|subscription|checkout|stripe|paypal|currency|card number|credit card|amount due|total due|payout)\b/i;
87
+ const SIGN_IN =
88
+ /\b(sign ?in|sign ?out|sign ?up|log ?in|log ?out|login|logout|signin|signup|auth|oauth|session token|access token|refresh token|password|passcode|credential|credentials|api key|permission|permissions)\b/i;
89
+ const DATA_LOSS_ALWAYS =
90
+ /(\bdrop table\b|\btruncate\b|\bdelete from\b|\brm -rf\b|\bunlink\b|\bwipe\b|\bpurge\b|\berase\b|\bdata loss\b|\bdestroy\b)/i;
91
+ const DATA_LOSS_IN_EFFECTS = /\b(delete|deleted|remove all|clear all|migration|migrate|overwrite)\b/i;
92
+ const CRASH =
93
+ /\b(crash|crashed|uncaught|unhandled|fatal|segfault|panic|out of memory|stack overflow|core dumped|nonzero exit|exit code)\b/i;
94
+
95
+ /**
96
+ * How much each channel is worth when nothing else separates two findings. A
97
+ * call that went out or an error that appeared deserves more attention than a
98
+ * count that moved; a picture is worth least, because by the time it reaches
99
+ * here it is only evidence for something another channel already said.
100
+ *
101
+ * @type {Record<Channel, number>}
102
+ */
103
+ const CHANNEL_WEIGHT = {
104
+ effects: 5,
105
+ complaints: 5,
106
+ results: 4,
107
+ contract: 4,
108
+ meaning: 3,
109
+ counters: 2,
110
+ pixels: 1,
111
+ };
112
+
113
+ /**
114
+ * @typedef {object} ChangedHunk
115
+ * @property {string} file Repo-relative, the "after" side.
116
+ * @property {string} header The hunk header line, exactly as git wrote it.
117
+ * @property {string} fileHeader The "diff --git" block this hunk belongs to.
118
+ * @property {string} text The hunk, header line included.
119
+ * @property {string} patch A complete one-hunk patch, ready for `git apply`.
120
+ * @property {number} newStart
121
+ * @property {number} newLines
122
+ */
123
+
124
+ /**
125
+ * @typedef {object} Changed
126
+ * @property {boolean} ok
127
+ * @property {string} [why] Why we could not tell, in plain language.
128
+ * @property {string[]} files Tracked files with changes, repo-relative.
129
+ * @property {string[]} untracked New files git has never seen, repo-relative.
130
+ * @property {ChangedHunk[]} hunks
131
+ * @property {string} patch The whole working diff, as one patch.
132
+ * @property {string} root Absolute repo root.
133
+ */
134
+
135
+ /**
136
+ * Put the findings in the order somebody should read them, and fill in the class
137
+ * and the sentence explaining the placing.
138
+ *
139
+ * @param {Finding[]} findings
140
+ * @param {{
141
+ * cwd: string,
142
+ * guards?: string[],
143
+ * touches?: Record<string, string[]>,
144
+ * changed?: Changed,
145
+ * maxHops?: number,
146
+ * }} opts
147
+ * @returns {Promise<{findings: Finding[], notes: string[], youChanged: string[]}>}
148
+ */
149
+ export async function rankFindings(findings, opts) {
150
+ /** @type {string[]} */
151
+ const notes = [];
152
+ const guards = opts.guards ?? [];
153
+ const changed = opts.changed ?? (await whatChanged(opts.cwd));
154
+ if (!changed.ok && changed.why) {
155
+ notes.push(
156
+ `${changed.why} Findings are ordered by what kind of thing they are instead of by how far they sit from your edit.`,
157
+ );
158
+ }
159
+
160
+ const seeds = [...changed.files, ...changed.untracked].map((f) => path.resolve(changed.root, f));
161
+ /** @type {Map<string, number>} */
162
+ let distances = new Map();
163
+ if (seeds.length > 0) {
164
+ const graph = await importGraph(changed.root);
165
+ if (graph.truncated) {
166
+ notes.push(
167
+ 'This project has more source files than the distance measure will walk, so some findings say their distance is unknown.',
168
+ );
169
+ }
170
+ distances = distancesFrom(graph.neighbours, seeds, opts.maxHops ?? MAX_HOPS);
171
+ } else if (changed.ok) {
172
+ notes.push('Nothing in the working tree has changed, so none of this can be blamed on an edit you just made.');
173
+ }
174
+
175
+ const ranked = findings.map((finding) => {
176
+ const sealedClass = classOf(finding, guards);
177
+ const distance = distanceFor(finding, distances, changed.root, opts.touches ?? {});
178
+ /** @type {Finding} */
179
+ const out = {
180
+ ...finding,
181
+ class: sealedClass,
182
+ sealed: sealedClass !== 'ordinary',
183
+ rank: scoreOf(finding, sealedClass, distance),
184
+ why: explain(finding, sealedClass, distance, seeds.length > 0),
185
+ };
186
+ const near = nearestFiles(finding, distances, changed.root);
187
+ if (near.length > 0) out.nearFiles = near;
188
+ return out;
189
+ });
190
+
191
+ ranked.sort((a, b) => b.rank - a.rank || (b.count ?? 0) - (a.count ?? 0) || a.id.localeCompare(b.id));
192
+ return { findings: ranked, notes, youChanged: [...changed.files, ...changed.untracked] };
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // The sealed classes
197
+ // ---------------------------------------------------------------------------
198
+
199
+ /**
200
+ * Which class of "nobody may wave this through" a finding falls in.
201
+ *
202
+ * The words are matched against everything the finding says about itself, not
203
+ * only its addresses, because a difference in what a call SENDS is as much a
204
+ * money difference as a difference in what a screen calls a button.
205
+ *
206
+ * @param {Finding} finding
207
+ * @param {string[]} guards Guard names, so a difference touching one is sealed by name.
208
+ * @returns {FindingClass}
209
+ */
210
+ export function classOf(finding, guards) {
211
+ const haystack = [
212
+ finding.title,
213
+ finding.signature ?? '',
214
+ ...finding.differences.map((d) => d.path),
215
+ ...finding.differences.map((d) => d.describe ?? ''),
216
+ ...journeysOf(finding),
217
+ ].join(' \n ');
218
+
219
+ const channels = new Set(finding.differences.map((d) => d.channel));
220
+ for (const name of guards) {
221
+ if (name && haystack.toLowerCase().includes(name.toLowerCase())) return 'guard';
222
+ }
223
+ 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';
226
+ // The softer words — "delete", "migrate" — only seal when something actually
227
+ // went out or came back. A button labelled Delete that changed colour is not a
228
+ // data-loss incident, and treating it as one is how a safety net gets ignored.
229
+ if (
230
+ DATA_LOSS_IN_EFFECTS.test(haystack) &&
231
+ (channels.has('effects') || channels.has('results') || channels.has('contract'))
232
+ ) {
233
+ return 'data-loss';
234
+ }
235
+ if (MONEY.test(haystack)) return 'money';
236
+ if (SIGN_IN.test(haystack)) return 'sign-in';
237
+ return 'ordinary';
238
+ }
239
+
240
+ /**
241
+ * @param {Finding} finding
242
+ * @param {FindingClass} sealedClass
243
+ * @param {number|null} distance
244
+ */
245
+ function scoreOf(finding, sealedClass, distance) {
246
+ if (sealedClass !== 'ordinary') {
247
+ // Sealed findings live above everything else, ordered among themselves by
248
+ // how much damage the class can do. The gap is deliberately enormous, so no
249
+ // amount of distance or spread can lift an ordinary finding into them.
250
+ const rank = SEALED_ORDER.indexOf(sealedClass);
251
+ return 10_000 - rank * 100 + Math.min(finding.count ?? 1, 50);
252
+ }
253
+
254
+ // 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));
257
+ // Something appearing or vanishing is worth more than something moving: a
258
+ // whole address arriving or leaving is a shape change, not a value change.
259
+ const shape = finding.differences.some((d) => d.kind !== 'changed') ? 4 : 0;
260
+ const spread = Math.min(4, Math.floor(Math.log2((finding.count ?? 1) + 1)));
261
+ return far * 10 + channel * 3 + shape + spread;
262
+ }
263
+
264
+ /**
265
+ * @param {Finding} finding
266
+ * @param {FindingClass} sealedClass
267
+ * @param {number|null} distance
268
+ * @param {boolean} knewWhatChanged
269
+ */
270
+ function explain(finding, sealedClass, distance, knewWhatChanged) {
271
+ if (sealedClass !== 'ordinary') {
272
+ return `Nobody may wave this through on their own: it touches ${SEAL_WORDS[sealedClass]}. It goes to a person whatever caused it.`;
273
+ }
274
+ if (!knewWhatChanged) {
275
+ return 'Nothing in the working tree has changed, so there is no edit to measure this against.';
276
+ }
277
+ if (distance === null) {
278
+ 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
+ }
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.`;
283
+ }
284
+
285
+ /**
286
+ * How far a finding sits from the nearest thing the agent edited.
287
+ *
288
+ * The finding's own source files are trusted first. When it has none, the
289
+ * journey's list of files is used — but only when that list is short. A journey
290
+ * that went through four hundred files touches everything, and letting it answer
291
+ * would put every finding at distance zero and quietly switch the ranking off.
292
+ *
293
+ * @param {Finding} finding
294
+ * @param {Map<string, number>} distances
295
+ * @param {string} root
296
+ * @param {Record<string, string[]>} touches
297
+ * @returns {number|null}
298
+ */
299
+ function distanceFor(finding, distances, root, touches) {
300
+ if (distances.size === 0) return null;
301
+ /** @type {number[]} */
302
+ const found = [];
303
+
304
+ /** @param {string|undefined} file */
305
+ const look = (file) => {
306
+ if (!file) return;
307
+ const abs = path.isAbsolute(file) ? file : path.resolve(root, file);
308
+ const hops = distances.get(abs);
309
+ if (typeof hops === 'number') found.push(hops);
310
+ };
311
+
312
+ for (const file of finding.nearFiles ?? []) look(file);
313
+
314
+ if (found.length === 0) {
315
+ for (const journey of journeysOf(finding)) {
316
+ const files = touches[journey];
317
+ if (!files || files.length === 0 || files.length > 25) continue;
318
+ for (const file of files) look(file);
319
+ }
320
+ }
321
+ return found.length > 0 ? Math.min(...found) : null;
322
+ }
323
+
324
+ /**
325
+ * The finding's source files, nearest to the edit first, so a reader opens the
326
+ * most useful one.
327
+ *
328
+ * @param {Finding} finding
329
+ * @param {Map<string, number>} distances
330
+ * @param {string} root
331
+ * @returns {string[]}
332
+ */
333
+ function nearestFiles(finding, distances, root) {
334
+ const files = finding.nearFiles ?? [];
335
+ if (files.length < 2 || distances.size === 0) return files;
336
+ return [...files].sort((a, b) => {
337
+ const da = distances.get(path.resolve(root, a)) ?? Number.MAX_SAFE_INTEGER;
338
+ const db = distances.get(path.resolve(root, b)) ?? Number.MAX_SAFE_INTEGER;
339
+ return da - db;
340
+ });
341
+ }
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // What the agent just changed
345
+ // ---------------------------------------------------------------------------
346
+
347
+ /**
348
+ * Read the working tree's diff: which files, and which hunks inside them.
349
+ *
350
+ * Read-only, and allowed to fail. A project that is not in git still gets
351
+ * checked - it just gets its findings ordered by kind rather than by distance,
352
+ * and it is told so.
353
+ *
354
+ * @param {string} cwd
355
+ * @returns {Promise<Changed>}
356
+ */
357
+ export async function whatChanged(cwd) {
358
+ const root = await git(['rev-parse', '--show-toplevel'], cwd);
359
+ if (!root) {
360
+ return {
361
+ ok: false,
362
+ why: 'This folder is not inside a git repository, so there is no way to know what you have just changed.',
363
+ files: [],
364
+ untracked: [],
365
+ hunks: [],
366
+ patch: '',
367
+ root: cwd,
368
+ };
369
+ }
370
+ const head = await git(['rev-parse', '--verify', 'HEAD'], cwd);
371
+ if (!head) {
372
+ return {
373
+ ok: false,
374
+ why: 'This repository has no commits yet, so there is nothing to compare the working tree against.',
375
+ files: [],
376
+ untracked: [],
377
+ hunks: [],
378
+ patch: '',
379
+ root,
380
+ };
381
+ }
382
+
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)) ?? '';
386
+
387
+ return {
388
+ ok: true,
389
+ files: lines(names),
390
+ untracked: lines(others),
391
+ hunks: parseHunks(patch),
392
+ patch,
393
+ root,
394
+ };
395
+ }
396
+
397
+ /**
398
+ * Split a unified diff into hunks, each carrying enough of its file's header to
399
+ * be applied - or reversed - on its own.
400
+ *
401
+ * @param {string} patch
402
+ * @returns {ChangedHunk[]}
403
+ */
404
+ export function parseHunks(patch) {
405
+ /** @type {ChangedHunk[]} */
406
+ const hunks = [];
407
+ if (!patch) return hunks;
408
+
409
+ const all = patch.split('\n');
410
+ /** @type {string[]} */
411
+ let header = [];
412
+ let file = '';
413
+ /** @type {string[]|null} */
414
+ let body = null;
415
+ let at = '';
416
+ let newStart = 0;
417
+ let newLines = 0;
418
+
419
+ const flush = () => {
420
+ if (body === null) return;
421
+ // The final split of the whole patch leaves an empty string on the end. Left
422
+ // in, it becomes a phantom context line and `git apply --recount` refuses the
423
+ // hunk. A real empty context line in a diff is a single space, never nothing.
424
+ while (body.length > 0 && body[body.length - 1] === '') body.pop();
425
+ const text = body.join('\n');
426
+ hunks.push({
427
+ file,
428
+ header: at,
429
+ fileHeader: header.join('\n'),
430
+ text,
431
+ patch: `${header.join('\n')}\n${text}\n`,
432
+ newStart,
433
+ newLines,
434
+ });
435
+ body = null;
436
+ };
437
+
438
+ for (const line of all) {
439
+ if (line.startsWith('diff --git ')) {
440
+ flush();
441
+ header = [line];
442
+ file = '';
443
+ continue;
444
+ }
445
+ if (body === null && !line.startsWith('@@')) {
446
+ // Still in the file's header: index lines, mode lines, --- and +++.
447
+ if (header.length > 0) header.push(line);
448
+ if (line.startsWith('+++ b/')) file = line.slice(6);
449
+ else if (line.startsWith('--- a/') && file === '') file = line.slice(6);
450
+ continue;
451
+ }
452
+ if (line.startsWith('@@')) {
453
+ flush();
454
+ at = line;
455
+ const m = /@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
456
+ newStart = m ? Number(m[1]) : 0;
457
+ newLines = m && m[2] !== undefined ? Number(m[2]) : 1;
458
+ body = [line];
459
+ continue;
460
+ }
461
+ if (body === null) continue;
462
+ body.push(line);
463
+ }
464
+ flush();
465
+ return hunks;
466
+ }
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // The import graph
470
+ // ---------------------------------------------------------------------------
471
+
472
+ /**
473
+ * A cheap map of who imports whom.
474
+ *
475
+ * Regexes, not a parser. It will miss a dynamic import built out of a variable
476
+ * and it will not follow a bundler alias, and neither matters: this number is
477
+ * only ever used to sort a list. Being approximately right instantly beats being
478
+ * exactly right in a minute.
479
+ *
480
+ * @param {string} root
481
+ * @param {{maxFiles?: number}} [opts]
482
+ * @returns {Promise<{neighbours: Map<string, Set<string>>, files: string[], truncated: boolean}>}
483
+ */
484
+ export async function importGraph(root, opts = {}) {
485
+ const limit = opts.maxFiles ?? MAX_FILES;
486
+ const files = await sourceFiles(root, limit);
487
+ /** @type {Map<string, Set<string>>} */
488
+ const neighbours = new Map();
489
+ const known = new Set(files.list);
490
+
491
+ /** @param {string} a @param {string} b */
492
+ const join = (a, b) => {
493
+ const set = neighbours.get(a) ?? new Set();
494
+ set.add(b);
495
+ neighbours.set(a, set);
496
+ };
497
+
498
+ for (const file of files.list) {
499
+ let text = '';
500
+ try {
501
+ const stat = await fsp.stat(file);
502
+ if (stat.size > MAX_FILE_BYTES) continue;
503
+ text = await fsp.readFile(file, 'utf8');
504
+ } catch {
505
+ continue;
506
+ }
507
+ for (const spec of specifiersIn(text)) {
508
+ if (!spec.startsWith('.')) continue;
509
+ const target = resolveNearby(path.dirname(file), spec, known);
510
+ if (!target || target === file) continue;
511
+ // Undirected: a side effect travels both ways along an import, and for a
512
+ // number that only orders a list, direction is not worth the extra pass.
513
+ join(file, target);
514
+ join(target, file);
515
+ }
516
+ }
517
+
518
+ return { neighbours, files: files.list, truncated: files.truncated };
519
+ }
520
+
521
+ /**
522
+ * How many imports away each file is from the nearest thing that changed.
523
+ *
524
+ * @param {Map<string, Set<string>>} neighbours
525
+ * @param {string[]} seeds
526
+ * @param {number} maxHops
527
+ * @returns {Map<string, number>}
528
+ */
529
+ export function distancesFrom(neighbours, seeds, maxHops) {
530
+ /** @type {Map<string, number>} */
531
+ const distance = new Map();
532
+ /** @type {string[]} */
533
+ let edge = [];
534
+ for (const seed of seeds) {
535
+ distance.set(seed, 0);
536
+ edge.push(seed);
537
+ }
538
+ let hops = 0;
539
+ while (edge.length > 0 && hops < maxHops) {
540
+ hops += 1;
541
+ /** @type {string[]} */
542
+ const next = [];
543
+ for (const file of edge) {
544
+ for (const neighbour of neighbours.get(file) ?? []) {
545
+ if (distance.has(neighbour)) continue;
546
+ distance.set(neighbour, hops);
547
+ next.push(neighbour);
548
+ }
549
+ }
550
+ edge = next;
551
+ }
552
+ return distance;
553
+ }
554
+
555
+ /**
556
+ * Every source file under a root, without ever walking into somebody else's.
557
+ *
558
+ * @param {string} root
559
+ * @param {number} limit
560
+ * @returns {Promise<{list: string[], truncated: boolean}>}
561
+ */
562
+ async function sourceFiles(root, limit) {
563
+ /** @type {string[]} */
564
+ const list = [];
565
+ /** @type {string[]} */
566
+ const queue = [root];
567
+ let truncated = false;
568
+ while (queue.length > 0) {
569
+ const dir = queue.pop();
570
+ if (dir === undefined) break;
571
+ /** @type {import('node:fs').Dirent[]} */
572
+ let entries = [];
573
+ try {
574
+ entries = await fsp.readdir(dir, { withFileTypes: true });
575
+ } catch {
576
+ continue;
577
+ }
578
+ for (const entry of entries) {
579
+ if (entry.isDirectory()) {
580
+ // Hidden folders are tooling, not product. Walking them costs time and
581
+ // adds nothing a distance measure can use.
582
+ if (entry.name.startsWith('.') || NOT_SOURCE.has(entry.name)) continue;
583
+ queue.push(path.join(dir, entry.name));
584
+ continue;
585
+ }
586
+ if (!entry.isFile()) continue;
587
+ if (!SOURCE_EXTENSIONS.includes(path.extname(entry.name))) continue;
588
+ if (list.length >= limit) {
589
+ truncated = true;
590
+ return { list, truncated };
591
+ }
592
+ list.push(path.join(dir, entry.name));
593
+ }
594
+ }
595
+ return { list, truncated };
596
+ }
597
+
598
+ /**
599
+ * Every module specifier a file mentions.
600
+ * @param {string} text
601
+ * @returns {string[]}
602
+ */
603
+ export function specifiersIn(text) {
604
+ /** @type {string[]} */
605
+ const out = [];
606
+ const patterns = [
607
+ /\bfrom\s+['"]([^'"]+)['"]/g,
608
+ /\bimport\s+['"]([^'"]+)['"]/g,
609
+ /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
610
+ /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
611
+ ];
612
+ for (const pattern of patterns) {
613
+ /** @type {RegExpExecArray|null} */
614
+ let m = null;
615
+ while ((m = pattern.exec(text)) !== null) out.push(m[1]);
616
+ }
617
+ return out;
618
+ }
619
+
620
+ /**
621
+ * Turn a relative specifier into a file we actually know about.
622
+ *
623
+ * The `.js` to `.ts` fallback is here because TypeScript projects import a file
624
+ * by the name it will have after it is built, which is not the name it has on
625
+ * disk. Without this the graph is empty on half the projects it will meet.
626
+ *
627
+ * @param {string} from
628
+ * @param {string} spec
629
+ * @param {Set<string>} known
630
+ * @returns {string|null}
631
+ */
632
+ function resolveNearby(from, spec, known) {
633
+ const base = path.resolve(from, spec);
634
+ /** @type {string[]} */
635
+ const tries = [base];
636
+ for (const ext of SOURCE_EXTENSIONS) tries.push(base + ext);
637
+ for (const ext of SOURCE_EXTENSIONS) tries.push(path.join(base, 'index' + ext));
638
+ const ext = path.extname(base);
639
+ if (ext === '.js' || ext === '.mjs' || ext === '.cjs') {
640
+ const stem = base.slice(0, -ext.length);
641
+ for (const swap of ['.ts', '.tsx', '.mts', '.cts']) tries.push(stem + swap);
642
+ }
643
+ for (const candidate of tries) if (known.has(candidate)) return candidate;
644
+ return null;
645
+ }
646
+
647
+ // ---------------------------------------------------------------------------
648
+ // git, quietly
649
+ // ---------------------------------------------------------------------------
650
+
651
+ /**
652
+ * @param {string[]} args
653
+ * @param {string} cwd
654
+ * @param {boolean} [keepBlankLines]
655
+ * @returns {Promise<string|null>}
656
+ */
657
+ async function git(args, cwd, keepBlankLines = false) {
658
+ try {
659
+ 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;
663
+ }
664
+ }
665
+
666
+ /** @param {string} text */
667
+ function lines(text) {
668
+ return text
669
+ .split('\n')
670
+ .map((l) => l.trim())
671
+ .filter((l) => l.length > 0);
672
+ }