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/CHANGELOG.md +187 -0
- package/README.md +104 -43
- package/docs/design-v2.md +275 -0
- package/docs/getting-started.md +295 -0
- package/docs/guards.md +226 -0
- package/docs/how-it-stays-stable.md +315 -0
- package/docs/how-v2-works.md +403 -0
- package/docs/mcp.md +286 -0
- package/docs/running-it-in-ci.md +306 -0
- package/docs/watching.md +190 -0
- package/package.json +3 -3
- package/src/cli/index.js +34 -7
- package/src/core/config.js +12 -2
- package/src/v2/adapters/isolate.js +89 -0
- package/src/v2/cause.js +151 -12
- package/src/v2/check.js +345 -3
- package/src/v2/cli.js +71 -12
- package/src/v2/cluster.js +20 -3
- package/src/v2/coverage.js +40 -5
- package/src/v2/detect.js +1413 -20
- package/src/v2/doctor.js +191 -35
- package/src/v2/init.js +470 -57
- package/src/v2/mcp/tools.js +124 -11
- package/src/v2/normalise.js +54 -7
- package/src/v2/observation.js +56 -10
- package/src/v2/rank.js +212 -43
- package/src/v2/run.js +216 -31
- package/src/v2/selfcheck.js +312 -7
- package/src/v2/store.js +269 -45
- package/src/v2/watch/index.js +96 -17
- package/src/v2/watch/window.js +138 -20
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
|
-
|
|
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,
|
|
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
|
|
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,
|
|
184
|
-
why: explain(finding, sealedClass,
|
|
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
|
|
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
|
|
225
|
-
if (DATA_LOSS_ALWAYS
|
|
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
|
|
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
|
|
236
|
-
if (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 {
|
|
329
|
+
* @param {HowFar} how
|
|
244
330
|
*/
|
|
245
|
-
function scoreOf(finding, sealedClass,
|
|
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
|
-
|
|
256
|
-
|
|
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 {
|
|
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,
|
|
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 (
|
|
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
|
-
* @
|
|
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
|
-
|
|
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
|
|
384
|
-
const names =
|
|
385
|
-
const others =
|
|
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
|
-
|
|
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(
|
|
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)
|
|
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
|
|
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
|
|
661
|
-
} catch {
|
|
662
|
-
|
|
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
|
|