@haystackeditor/cli 0.17.1 → 0.18.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/README.md +206 -24
- package/dist/commands/case-batch-contract.js +2 -2
- package/dist/commands/case-batch.js +7 -17
- package/dist/commands/crawl-contract.js +3 -0
- package/dist/commands/db-profile-contract.js +419 -0
- package/dist/commands/db-profile-database.js +289 -0
- package/dist/commands/db-profile-distinct.js +97 -0
- package/dist/commands/db-profile-owners.js +94 -0
- package/dist/commands/db-profile-privacy.js +450 -0
- package/dist/commands/db-profile-show.js +218 -0
- package/dist/commands/db-profile-sql-literals.js +140 -0
- package/dist/commands/db-profile-sql.js +362 -0
- package/dist/commands/db-profile-upload.js +88 -0
- package/dist/commands/db-profile.js +1020 -0
- package/dist/commands/fleet-policy-contract.js +28 -0
- package/dist/commands/fleet-policy.js +305 -0
- package/dist/commands/precompute-delivery-worker.js +17 -3
- package/dist/commands/precompute-delivery.js +16 -6
- package/dist/commands/verify-hosted.js +20 -0
- package/dist/commands/verify-precompute.js +140 -95
- package/dist/commands/verify.js +523 -52
- package/dist/index.js +172 -20
- package/dist/schema.js +1 -0
- package/dist/utils/verify-base.js +31 -0
- package/package.json +4 -1
- package/schemas/verify.v1.json +217 -0
- package/dist/commands/combination-search-hook-contract.js +0 -1
- package/dist/commands/combination-search-hook.js +0 -139
package/dist/commands/verify.js
CHANGED
|
@@ -1,64 +1,535 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* `haystack verify` — the crawl of the current change (CRAWL-V1, amendment 1).
|
|
3
|
+
*
|
|
4
|
+
* It captures the checkout (committed and uncommitted) exactly as the stop
|
|
5
|
+
* hook does, finds the caller's crawl for that exact capture (its code and its
|
|
6
|
+
* change title, which is part of a crawl's identity and informs the judge), and
|
|
7
|
+
* submits the capture through the same front-half request `haystack verify
|
|
8
|
+
* precompute` sends when there is none, or when the one found ended cancelled
|
|
9
|
+
* or incomplete or is being cancelled (the service runs it again). It follows
|
|
10
|
+
* the acknowledged run by its id. It then waits (no wall-time cap: Ctrl-C
|
|
11
|
+
* stops waiting, never the crawl) and prints where the change shows up in the
|
|
12
|
+
* running app and what the crawl found. The hosted fleet run of pushed
|
|
13
|
+
* commits is `haystack verify hosted start|status`.
|
|
14
|
+
*/
|
|
15
|
+
import chalk from 'chalk';
|
|
16
|
+
import { withSchema } from '../schema.js';
|
|
17
|
+
import { resolveAuthContext } from '../utils/auth.js';
|
|
18
|
+
import { classifyHttpError, HaystackApiError } from '../utils/haystack-api.js';
|
|
19
|
+
import { gatewayFetch } from './case-batch.js';
|
|
20
|
+
import { CRAWL_MAX_FINDINGS, CRAWL_MAX_FINDING_STEPS, } from './crawl-contract.js';
|
|
21
|
+
import { captureCheckout, crawlUnavailableText, EXPLICIT_WALL_MS, normalModeFailure, postPrecomputeCapture, } from './verify-precompute.js';
|
|
22
|
+
const CRAWLS_PATH = '/api/agent/cloud-verifier/crawls';
|
|
23
|
+
const DEFAULT_INTERVAL_SECONDS = 5;
|
|
24
|
+
const MAX_INTERVAL_SECONDS = 300;
|
|
25
|
+
const RUN_ID = /^cv_[0-9a-f]{48}$/;
|
|
26
|
+
const REPOSITORY_COMPONENT = /^[A-Za-z0-9_.-]{1,100}$/;
|
|
27
|
+
const STATUSES = ['queued', 'preparing', 'crawling', 'draining', 'completed', 'incomplete', 'cancelled'];
|
|
28
|
+
const TERMINAL = new Set(['completed', 'incomplete', 'cancelled']);
|
|
29
|
+
const STAGES = ['reconcile', 'source', 'worlds', 'blast', 'knobs', 'prepare', 'crawl', 'publish', 'cleanup'];
|
|
30
|
+
const ERROR_CODES = ['source-mismatch', 'blast-unavailable', 'world-build-failed',
|
|
31
|
+
'prepare-failed', 'crawl-failed', 'no-capacity', 'stalled', 'lease-lost', 'cancelled'];
|
|
32
|
+
const VERDICTS = ['bug', 'cant-tell', 'being-judged', 'not-a-bug'];
|
|
33
|
+
const SPOT_STAGES = ['resolved', 'loaded', 'exposed', 'activated', 'pressed', 'observed', 'confirmed', 'judged'];
|
|
34
|
+
const REUSE = ['hit', 'miss', null];
|
|
35
|
+
const STATUS_WORDS = {
|
|
36
|
+
queued: 'waiting for a machine',
|
|
37
|
+
preparing: 'getting ready',
|
|
38
|
+
crawling: 'exploring the app',
|
|
39
|
+
draining: 'finishing the last checks',
|
|
40
|
+
completed: 'finished',
|
|
41
|
+
incomplete: 'stopped before finishing',
|
|
42
|
+
cancelled: 'cancelled',
|
|
43
|
+
};
|
|
44
|
+
const STAGE_WORDS = {
|
|
45
|
+
reconcile: 'cleaning up after an earlier try',
|
|
46
|
+
source: 'rebuilding your change',
|
|
47
|
+
worlds: 'building and starting the app with and without your change',
|
|
48
|
+
blast: 'waiting for the analysis of what your change touches',
|
|
49
|
+
knobs: "reading the app's settings",
|
|
50
|
+
prepare: 'setting up starting points next to your change',
|
|
51
|
+
crawl: 'exploring the app',
|
|
52
|
+
publish: 'saving the results',
|
|
53
|
+
cleanup: 'shutting down the machines it used',
|
|
54
|
+
};
|
|
55
|
+
const ERROR_WORDS = {
|
|
56
|
+
'source-mismatch': 'your change could not be rebuilt exactly as it was captured',
|
|
57
|
+
'blast-unavailable': 'the analysis of what your change touches was not available',
|
|
58
|
+
'world-build-failed': 'the app did not build or start, with or without your change',
|
|
59
|
+
'prepare-failed': 'setting up starting points next to your change failed',
|
|
60
|
+
'crawl-failed': 'exploring the app failed',
|
|
61
|
+
'no-capacity': 'there was no room on the machines for it',
|
|
62
|
+
stalled: 'it stopped making progress',
|
|
63
|
+
'lease-lost': 'the machine running it stopped responding',
|
|
64
|
+
cancelled: 'it was cancelled',
|
|
65
|
+
};
|
|
66
|
+
const SPOT_WORDS = {
|
|
67
|
+
resolved: 'never reached',
|
|
68
|
+
loaded: 'loaded but never ran',
|
|
69
|
+
exposed: 'shown on screen',
|
|
70
|
+
activated: 'ran',
|
|
71
|
+
pressed: 'ran when its control was pressed',
|
|
72
|
+
observed: 'a difference seen',
|
|
73
|
+
confirmed: 'a difference seen and confirmed',
|
|
74
|
+
judged: 'judged a bug',
|
|
75
|
+
};
|
|
76
|
+
const VERDICT_WORDS = {
|
|
77
|
+
bug: 'Bug',
|
|
78
|
+
'cant-tell': "Can't tell",
|
|
79
|
+
'being-judged': 'Still being judged',
|
|
80
|
+
'not-a-bug': 'Not a bug',
|
|
81
|
+
};
|
|
82
|
+
/** A response the CLI will not print: malformed, contradictory, or another revision's. */
|
|
83
|
+
export class CrawlResponseError extends Error {
|
|
84
|
+
constructor(message) {
|
|
85
|
+
super(message);
|
|
86
|
+
this.name = 'CrawlResponseError';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function isRecord(value) {
|
|
90
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
91
|
+
}
|
|
92
|
+
function isCount(value) {
|
|
93
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
94
|
+
}
|
|
95
|
+
function isTimestamp(value) {
|
|
96
|
+
return typeof value === 'string' && Number.isFinite(Date.parse(value));
|
|
97
|
+
}
|
|
98
|
+
function isMember(value, members) {
|
|
99
|
+
return members.includes(value);
|
|
100
|
+
}
|
|
101
|
+
function isStrings(value, maximum = Infinity) {
|
|
102
|
+
return Array.isArray(value) && value.length <= maximum && value.every(item => typeof item === 'string');
|
|
103
|
+
}
|
|
104
|
+
function invalid(what) {
|
|
105
|
+
throw new CrawlResponseError(`The crawl service returned an invalid crawl (${what}); nothing is shown.`);
|
|
106
|
+
}
|
|
107
|
+
function checkError(value, what) {
|
|
108
|
+
if (!isRecord(value) || !isMember(value.code, ERROR_CODES) || !isMember(value.stage, STAGES)
|
|
109
|
+
|| typeof value.message !== 'string')
|
|
110
|
+
invalid(what);
|
|
111
|
+
}
|
|
112
|
+
function checkReuse(value, what) {
|
|
113
|
+
if (!isRecord(value) || !['baseWorld', 'headWorld', 'prepare', 'routes']
|
|
114
|
+
.every(key => isMember(value[key], REUSE)))
|
|
115
|
+
invalid(what);
|
|
116
|
+
}
|
|
117
|
+
function checkFinding(value) {
|
|
118
|
+
if (!isRecord(value) || typeof value.id !== 'string' || !isMember(value.verdict, VERDICTS)
|
|
119
|
+
|| (value.why !== null && typeof value.why !== 'string') || typeof value.start !== 'string'
|
|
120
|
+
|| !isStrings(value.steps, CRAWL_MAX_FINDING_STEPS) || !isRecord(value.observed) || !isRecord(value.codeThatRan)
|
|
121
|
+
|| typeof value.confirmedAtSeconds !== 'number' || !Number.isFinite(value.confirmedAtSeconds)
|
|
122
|
+
|| !isRecord(value.images)
|
|
123
|
+
|| (value.images.old !== null && typeof value.images.old !== 'string')
|
|
124
|
+
|| (value.images.new !== null && typeof value.images.new !== 'string'))
|
|
125
|
+
invalid('a finding');
|
|
126
|
+
}
|
|
127
|
+
function checkSpot(value) {
|
|
128
|
+
if (!isRecord(value) || typeof value.id !== 'string' || typeof value.file !== 'string'
|
|
129
|
+
|| !Number.isSafeInteger(value.line) || value.line < 1
|
|
130
|
+
|| (value.furthest !== null && !isMember(value.furthest, SPOT_STAGES))
|
|
131
|
+
|| !isStrings(value.shows) || !isStrings(value.findingIds))
|
|
132
|
+
invalid('a changed spot');
|
|
133
|
+
const reached = value.reachedBy;
|
|
134
|
+
if (reached !== null && (!isRecord(reached) || !isMember(reached.stage, SPOT_STAGES)
|
|
135
|
+
|| typeof reached.start !== 'string' || !isStrings(reached.steps)
|
|
136
|
+
|| (reached.page !== null && typeof reached.page !== 'string')))
|
|
137
|
+
invalid('how a changed spot was reached');
|
|
138
|
+
}
|
|
139
|
+
function checkManifest(value, view) {
|
|
140
|
+
if (!isRecord(value) || value.schemaVersion !== 1)
|
|
141
|
+
invalid('its results');
|
|
142
|
+
// The published results belong to exactly this crawl and this revision.
|
|
143
|
+
if (value.runId !== view.runId || value.repository !== view.repository || value.baseCommit !== view.baseCommit
|
|
144
|
+
|| value.workCommit !== view.workCommit || value.treeSha !== view.treeSha || value.changeTitle !== view.changeTitle
|
|
145
|
+
|| value.status !== view.status) {
|
|
146
|
+
invalid('results that belong to a different crawl');
|
|
147
|
+
}
|
|
148
|
+
if (typeof value.attemptId !== 'string' || !isCount(value.bugs) || !isCount(value.notFinishedInTime))
|
|
149
|
+
invalid('its results');
|
|
150
|
+
if (value.worlds !== null && (!isRecord(value.worlds) || typeof value.worlds.base !== 'string'
|
|
151
|
+
|| typeof value.worlds.head !== 'string'))
|
|
152
|
+
invalid('its results');
|
|
153
|
+
checkReuse(value.reuse, 'its results');
|
|
154
|
+
if (!isRecord(value.timings) || !Object.values(value.timings)
|
|
155
|
+
.every(ms => typeof ms === 'number' && Number.isFinite(ms) && ms >= 0))
|
|
156
|
+
invalid('its stage timings');
|
|
157
|
+
if (!Array.isArray(value.findings) || value.findings.length > CRAWL_MAX_FINDINGS)
|
|
158
|
+
invalid('its findings');
|
|
159
|
+
for (const finding of value.findings)
|
|
160
|
+
checkFinding(finding);
|
|
161
|
+
const reach = value.reach;
|
|
162
|
+
if (!isRecord(reach) || !Array.isArray(reach.spots) || !isStrings(reach.unreachedChangedFiles))
|
|
163
|
+
invalid('its reach');
|
|
164
|
+
for (const spot of reach.spots)
|
|
165
|
+
checkSpot(spot);
|
|
166
|
+
const blast = reach.blast;
|
|
167
|
+
if (blast !== null && (!isRecord(blast) || !isCount(blast.functions) || !isCount(blast.ran)
|
|
168
|
+
|| !isCount(blast.unreached) || !isStrings(blast.nearestUnreached)))
|
|
169
|
+
invalid('its affected functions');
|
|
170
|
+
if (value.error !== null)
|
|
171
|
+
checkError(value.error, 'its error');
|
|
172
|
+
}
|
|
173
|
+
/** Accepts only a well-formed crawl of exactly this capture's revision. Another
|
|
174
|
+
* revision's crawl is refused, never shown. The change title is not checked
|
|
175
|
+
* here: the newest crawl of a revision may carry another title, which the
|
|
176
|
+
* caller compares (sameChange). */
|
|
177
|
+
export function parseCrawlView(value, expected) {
|
|
178
|
+
if (!isRecord(value) || typeof value.runId !== 'string' || !RUN_ID.test(value.runId))
|
|
179
|
+
invalid('its run id');
|
|
180
|
+
// GitHub repository names are case-insensitive; commits and trees are exact.
|
|
181
|
+
if (typeof value.repository !== 'string'
|
|
182
|
+
|| value.repository.toLowerCase() !== expected.repository.toLowerCase()
|
|
183
|
+
|| value.baseCommit !== expected.baseCommit || value.workCommit !== expected.workCommit
|
|
184
|
+
|| value.treeSha !== expected.treeSha) {
|
|
185
|
+
throw new CrawlResponseError('The crawl service returned a crawl of a different revision; it is not shown.');
|
|
186
|
+
}
|
|
187
|
+
if (!isMember(value.status, STATUSES) || !isMember(value.stage, STAGES)
|
|
188
|
+
|| typeof value.changeTitle !== 'string' || !isCount(value.observedAt)
|
|
189
|
+
|| !isTimestamp(value.createdAt) || !isTimestamp(value.updatedAt)
|
|
190
|
+
|| (value.supersededBy !== null && (typeof value.supersededBy !== 'string' || !RUN_ID.test(value.supersededBy)))
|
|
191
|
+
|| typeof value.cancelRequested !== 'boolean')
|
|
192
|
+
invalid('its status');
|
|
193
|
+
const clocks = value.clocks;
|
|
194
|
+
if (!isRecord(clocks) || !['startedAt', 'stageStartedAt', 'crawlStartedAt', 'finishedAt']
|
|
195
|
+
.every(key => clocks[key] === null || isTimestamp(clocks[key])) || !isCount(clocks.crawlBudgetMs))
|
|
196
|
+
invalid('its clocks');
|
|
197
|
+
checkReuse(value.reuse, 'what it reused');
|
|
198
|
+
if (!isRecord(value.worlds) || (value.worlds.base !== null && typeof value.worlds.base !== 'string')
|
|
199
|
+
|| (value.worlds.head !== null && typeof value.worlds.head !== 'string'))
|
|
200
|
+
invalid('its app builds');
|
|
201
|
+
const totals = value.totals;
|
|
202
|
+
if (!isRecord(totals) || !['clicks', 'differing', 'confirmed', 'bugs', 'vmsLaunched', 'cleanupProven', 'cleanupUnproven']
|
|
203
|
+
.every(key => isCount(totals[key])))
|
|
204
|
+
invalid('its totals');
|
|
205
|
+
if (value.error !== undefined)
|
|
206
|
+
checkError(value.error, 'its error');
|
|
207
|
+
if (value.manifest !== null) {
|
|
208
|
+
if (!TERMINAL.has(value.status))
|
|
209
|
+
invalid('results before it finished');
|
|
210
|
+
checkManifest(value.manifest, value);
|
|
211
|
+
}
|
|
212
|
+
return value;
|
|
213
|
+
}
|
|
214
|
+
/* ------------------------------------------------------------------- read */
|
|
215
|
+
/** The change title is part of a crawl's identity and informs its judge: a
|
|
216
|
+
* crawl of the same code under another title is not this change's crawl. */
|
|
217
|
+
function sameChange(view, identity) {
|
|
218
|
+
return view.changeTitle === identity.changeTitle;
|
|
219
|
+
}
|
|
220
|
+
async function responseBody(response) {
|
|
4
221
|
try {
|
|
5
|
-
return
|
|
222
|
+
return await response.json();
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
throw new CrawlResponseError(`The crawl service returned a body that is not JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** The caller's newest crawl of this capture's revision, or null. Its change
|
|
229
|
+
* title may differ from the capture's (sameChange). */
|
|
230
|
+
async function readCrawl(identity, token) {
|
|
231
|
+
const path = `${CRAWLS_PATH}?repository=${encodeURIComponent(identity.repository)}`
|
|
232
|
+
+ `&workCommit=${encodeURIComponent(identity.workCommit)}`;
|
|
233
|
+
const response = await gatewayFetch(path, token);
|
|
234
|
+
if (!response.ok)
|
|
235
|
+
throw await classifyHttpError(response, `Haystack API ${CRAWLS_PATH}`);
|
|
236
|
+
const body = await responseBody(response);
|
|
237
|
+
if (!isRecord(body) || !Object.hasOwn(body, 'crawl'))
|
|
238
|
+
invalid('no crawl field');
|
|
239
|
+
return body.crawl === null ? null : parseCrawlView(body.crawl, identity);
|
|
240
|
+
}
|
|
241
|
+
/** One crawl by its run id; it must be exactly this capture, change title included. */
|
|
242
|
+
async function readCrawlRun(runId, identity, token) {
|
|
243
|
+
if (!RUN_ID.test(runId))
|
|
244
|
+
invalid('an acknowledged run id');
|
|
245
|
+
const response = await gatewayFetch(`${CRAWLS_PATH}/${runId}?repository=${encodeURIComponent(identity.repository)}`, token);
|
|
246
|
+
if (!response.ok)
|
|
247
|
+
throw await classifyHttpError(response, `Haystack API ${CRAWLS_PATH}/${runId}`);
|
|
248
|
+
const view = parseCrawlView(await responseBody(response), identity);
|
|
249
|
+
if (view.runId !== runId || !sameChange(view, identity)) {
|
|
250
|
+
throw new CrawlResponseError(`The crawl service returned another crawl for ${runId}; it is not shown.`);
|
|
6
251
|
}
|
|
7
|
-
|
|
8
|
-
|
|
252
|
+
return view;
|
|
253
|
+
}
|
|
254
|
+
/* ----------------------------------------------------------------- output */
|
|
255
|
+
function safe(value) {
|
|
256
|
+
// Server text never moves the cursor or rewrites the terminal.
|
|
257
|
+
// eslint-disable-next-line no-control-regex -- replacing control characters is the point
|
|
258
|
+
return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ');
|
|
259
|
+
}
|
|
260
|
+
function plural(count, noun) {
|
|
261
|
+
return `${count} ${noun}${count === 1 ? '' : 's'}`;
|
|
262
|
+
}
|
|
263
|
+
function errorWords(error) {
|
|
264
|
+
return error.code === 'stalled'
|
|
265
|
+
? `${ERROR_WORDS.stalled} while ${STAGE_WORDS[error.stage]}`
|
|
266
|
+
: ERROR_WORDS[error.code];
|
|
267
|
+
}
|
|
268
|
+
/** What a running crawl is doing now: its stage, except while it waits for a
|
|
269
|
+
* machine or finishes its last checks, which its status says better. */
|
|
270
|
+
function currentStep(view) {
|
|
271
|
+
return view.status === 'queued' || view.status === 'draining' ? STATUS_WORDS[view.status] : STAGE_WORDS[view.stage];
|
|
272
|
+
}
|
|
273
|
+
function headline(view) {
|
|
274
|
+
const bugs = view.manifest?.bugs ?? view.totals.bugs;
|
|
275
|
+
const found = bugs > 0 ? ` It found ${plural(bugs, 'bug')} before it stopped.` : '';
|
|
276
|
+
switch (view.status) {
|
|
277
|
+
case 'completed':
|
|
278
|
+
return bugs > 0 ? `Found ${plural(bugs, 'bug')} in your change.` : 'No bugs found in your change.';
|
|
279
|
+
case 'incomplete': {
|
|
280
|
+
const error = view.manifest?.error ?? view.error;
|
|
281
|
+
return `The crawl could not finish: ${error ? errorWords(error) : 'no reason was recorded'}.${found}`;
|
|
282
|
+
}
|
|
283
|
+
case 'cancelled':
|
|
284
|
+
return `The crawl was cancelled${view.supersededBy ? ': a newer stop in this repository replaced it' : ''}.${found}`;
|
|
285
|
+
default: {
|
|
286
|
+
const so = bugs > 0 ? ` ${plural(bugs, 'bug')} found so far.` : '';
|
|
287
|
+
if (view.cancelRequested) {
|
|
288
|
+
return `Being cancelled${view.supersededBy ? ' (a newer stop in this repository replaced it)' : ''}: `
|
|
289
|
+
+ `${currentStep(view)}.${so}`;
|
|
290
|
+
}
|
|
291
|
+
return `Still running: ${currentStep(view)}.${so}`;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
function spotLines(spot) {
|
|
296
|
+
const how = spot.furthest === null ? 'never reached' : SPOT_WORDS[spot.furthest];
|
|
297
|
+
const lines = [` ${chalk.bold(`${safe(spot.file)}:${spot.line}`)} ${how}`];
|
|
298
|
+
if (spot.reachedBy) {
|
|
299
|
+
lines.push(` reached by: ${[spot.reachedBy.start, ...spot.reachedBy.steps].map(safe).join(' > ')}`);
|
|
300
|
+
if (spot.reachedBy.page !== null)
|
|
301
|
+
lines.push(` page: ${safe(spot.reachedBy.page)}`);
|
|
9
302
|
}
|
|
303
|
+
return lines;
|
|
10
304
|
}
|
|
11
|
-
function
|
|
12
|
-
const
|
|
13
|
-
|
|
305
|
+
function findingLines(finding, position) {
|
|
306
|
+
const verdict = VERDICT_WORDS[finding.verdict];
|
|
307
|
+
const label = finding.verdict === 'bug' ? chalk.red(verdict) : verdict;
|
|
308
|
+
const lines = [` ${position}. ${label}: ${finding.why === null ? 'no reason was recorded.' : safe(finding.why)}`,
|
|
309
|
+
` Starting from ${safe(finding.start)}:`];
|
|
310
|
+
finding.steps.forEach((step, index) => lines.push(` ${index + 1}. ${safe(step)}`));
|
|
311
|
+
return lines;
|
|
312
|
+
}
|
|
313
|
+
export function formatCrawl(view) {
|
|
314
|
+
const lines = [chalk.bold(headline(view))];
|
|
315
|
+
const error = view.manifest?.error ?? view.error;
|
|
316
|
+
if (error && view.status !== 'completed')
|
|
317
|
+
lines.push(chalk.dim(` Details: ${safe(error.message)}`));
|
|
318
|
+
lines.push(` Change: ${safe(view.changeTitle)}`, chalk.dim(` Crawl ${view.runId} of ${safe(view.repository)}: base ${view.baseCommit.slice(0, 12)},`
|
|
319
|
+
+ ` your change ${view.workCommit.slice(0, 12)}.`));
|
|
320
|
+
const manifest = view.manifest;
|
|
321
|
+
if (manifest === null) {
|
|
322
|
+
lines.push('', TERMINAL.has(view.status)
|
|
323
|
+
? 'No results were published for this crawl.'
|
|
324
|
+
: 'Where your change shows up in the app, and what the crawl found, appear when it finishes.');
|
|
325
|
+
return lines.join('\n');
|
|
326
|
+
}
|
|
327
|
+
// A crawl that stopped before exploring measured nothing: its empty reach and findings are not "nothing found".
|
|
328
|
+
if (view.clocks.crawlStartedAt === null) {
|
|
329
|
+
lines.push('', 'It stopped before exploring the app, so nothing about your change was measured.');
|
|
330
|
+
return lines.join('\n');
|
|
331
|
+
}
|
|
332
|
+
lines.push('', chalk.bold('Where your change shows up in the app'));
|
|
333
|
+
if (manifest.reach.spots.length === 0)
|
|
334
|
+
lines.push(' No changed spots were recorded.');
|
|
335
|
+
for (const spot of manifest.reach.spots)
|
|
336
|
+
lines.push(...spotLines(spot));
|
|
337
|
+
lines.push('', chalk.bold('What the crawl found'));
|
|
338
|
+
// Bugs first, then the undecided, then what the judge called intended.
|
|
339
|
+
const findings = VERDICTS.flatMap(verdict => manifest.findings.filter(finding => finding.verdict === verdict));
|
|
340
|
+
if (findings.length === 0)
|
|
341
|
+
lines.push(' No confirmed differences between the app with and without your change.');
|
|
342
|
+
findings.forEach((finding, index) => lines.push(...findingLines(finding, index + 1)));
|
|
343
|
+
if (manifest.notFinishedInTime > 0) {
|
|
344
|
+
lines.push(` ${plural(manifest.notFinishedInTime, 'difference')} ${manifest.notFinishedInTime === 1 ? 'was' : 'were'}`
|
|
345
|
+
+ ' still being checked when its time ran out.');
|
|
346
|
+
}
|
|
347
|
+
lines.push('', chalk.bold('What the crawl never ran'));
|
|
348
|
+
if (manifest.reach.unreachedChangedFiles.length === 0)
|
|
349
|
+
lines.push(' Every changed file ran at least once.');
|
|
350
|
+
else {
|
|
351
|
+
lines.push(' Changed files no path ran:');
|
|
352
|
+
for (const file of manifest.reach.unreachedChangedFiles)
|
|
353
|
+
lines.push(` ${safe(file)}`);
|
|
354
|
+
}
|
|
355
|
+
const blast = manifest.reach.blast;
|
|
356
|
+
if (blast !== null)
|
|
357
|
+
lines.push(` ${blast.ran} of ${plural(blast.functions, 'affected function')} ran.`);
|
|
358
|
+
if (view.totals.cleanupUnproven > 0) {
|
|
359
|
+
lines.push('', chalk.yellow(` ${plural(view.totals.cleanupUnproven, 'machine')} it used could not be proven shut down.`));
|
|
360
|
+
}
|
|
361
|
+
return lines.join('\n');
|
|
362
|
+
}
|
|
363
|
+
/** Case-batch status conventions: 0 finished, or still running with --no-wait;
|
|
364
|
+
* 2 ended without finishing (incomplete, cancelled) or cleanup unproven; bugs
|
|
365
|
+
* found never change it. The command's own failures exit 1 elsewhere. */
|
|
366
|
+
export function crawlExitCode(view) {
|
|
367
|
+
if (!TERMINAL.has(view.status))
|
|
368
|
+
return 0;
|
|
369
|
+
if (view.status !== 'completed' || view.totals.cleanupUnproven > 0)
|
|
370
|
+
return 2;
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
/* ---------------------------------------------------------------- command */
|
|
374
|
+
function intervalMs(value) {
|
|
375
|
+
if (value === undefined)
|
|
376
|
+
return DEFAULT_INTERVAL_SECONDS * 1000;
|
|
377
|
+
const seconds = Number(value);
|
|
378
|
+
if (!Number.isFinite(seconds) || seconds <= 0 || seconds > MAX_INTERVAL_SECONDS) {
|
|
379
|
+
throw new Error(`--interval must be a positive number no greater than ${MAX_INTERVAL_SECONDS}.`);
|
|
380
|
+
}
|
|
381
|
+
return seconds * 1000;
|
|
382
|
+
}
|
|
383
|
+
function repositoryOverride(value) {
|
|
384
|
+
if (value === undefined)
|
|
385
|
+
return undefined;
|
|
386
|
+
const parts = value.split('/');
|
|
387
|
+
if (parts.length !== 2 || !parts.every(part => REPOSITORY_COMPONENT.test(part) && part !== '.' && part !== '..')) {
|
|
388
|
+
throw new Error(`Invalid repository "${value}". Use an exact GitHub owner/repository name.`);
|
|
389
|
+
}
|
|
390
|
+
return { owner: parts[0], repository: parts[1] };
|
|
391
|
+
}
|
|
392
|
+
function delay(milliseconds) {
|
|
393
|
+
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
394
|
+
}
|
|
395
|
+
function progress(line) {
|
|
396
|
+
console.error(chalk.dim(`${new Date().toTimeString().slice(0, 8)} ${line}`));
|
|
397
|
+
}
|
|
398
|
+
/** Polls the same capture until its crawl is terminal. No wall-time cap: an
|
|
399
|
+
* interrupt stops waiting and leaves the crawl running. */
|
|
400
|
+
async function waitForCrawl(first, identity, token, everyMs) {
|
|
401
|
+
const interrupted = () => {
|
|
402
|
+
console.error('\nStopped waiting. The crawl keeps running; run `haystack verify` again to see it.');
|
|
403
|
+
process.exit(130);
|
|
404
|
+
};
|
|
405
|
+
process.once('SIGINT', interrupted);
|
|
14
406
|
try {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
407
|
+
let view = first;
|
|
408
|
+
progress(`Waiting for crawl ${view.runId} (Ctrl-C stops waiting; the crawl keeps running).`);
|
|
409
|
+
progress(currentStep(view));
|
|
410
|
+
while (!TERMINAL.has(view.status)) {
|
|
411
|
+
await delay(everyMs);
|
|
412
|
+
let next = await readCrawl(identity, token);
|
|
413
|
+
if (next === null)
|
|
414
|
+
throw new CrawlResponseError(`The crawl service no longer returns crawl ${view.runId} for this change.`);
|
|
415
|
+
// A newer crawl of the same code under another title is another change's: keep following this one.
|
|
416
|
+
if (!sameChange(next, identity))
|
|
417
|
+
next = await readCrawlRun(view.runId, identity, token);
|
|
418
|
+
if (next.runId !== view.runId)
|
|
419
|
+
progress(`A newer crawl of this same change took over (${next.runId}).`);
|
|
420
|
+
if (!TERMINAL.has(next.status) && (next.runId !== view.runId || currentStep(next) !== currentStep(view))) {
|
|
421
|
+
progress(currentStep(next));
|
|
422
|
+
}
|
|
423
|
+
view = next;
|
|
424
|
+
}
|
|
425
|
+
return view;
|
|
426
|
+
}
|
|
427
|
+
finally {
|
|
428
|
+
process.removeListener('SIGINT', interrupted);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
function staleCrawl(found, identity) {
|
|
432
|
+
if (!sameChange(found, identity))
|
|
433
|
+
return 'retitled';
|
|
434
|
+
if (found.status === 'cancelled' || found.status === 'incomplete')
|
|
435
|
+
return found.status;
|
|
436
|
+
return !TERMINAL.has(found.status) && found.cancelRequested ? 'cancelling' : null;
|
|
437
|
+
}
|
|
438
|
+
function staleWords(found, stale) {
|
|
439
|
+
switch (stale) {
|
|
440
|
+
case 'retitled': return `The last crawl of this code ran under another title, "${safe(found.changeTitle)}" (${found.runId})`;
|
|
441
|
+
case 'cancelled': return `This change's last crawl was cancelled (${found.runId})`;
|
|
442
|
+
case 'incomplete': return `This change's last crawl stopped before finishing (${found.runId})`;
|
|
443
|
+
case 'cancelling': return `This change's crawl is being cancelled (${found.runId})`;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/** Submits the capture through the explicit precompute submission when no crawl
|
|
447
|
+
* of it exists or the one found is stale. Returns the acknowledged run id (null
|
|
448
|
+
* when no crawl could be started) and the line to report. */
|
|
449
|
+
async function submitCapture(capture, token, found) {
|
|
450
|
+
let acknowledgment;
|
|
451
|
+
// A momentary 429/5xx (a server or runner restarting) is asked again, as the stop hook's sender does: admission is
|
|
452
|
+
// idempotent for one capture, so a repeat can only find what the first created.
|
|
453
|
+
const pauses = [2_000, 5_000, 10_000];
|
|
454
|
+
for (let attempt = 0; acknowledgment === undefined; attempt += 1) {
|
|
38
455
|
try {
|
|
39
|
-
|
|
456
|
+
acknowledgment = await postPrecomputeCapture(capture, token, Date.now() + EXPLICIT_WALL_MS);
|
|
40
457
|
}
|
|
41
|
-
catch {
|
|
42
|
-
|
|
458
|
+
catch (error) {
|
|
459
|
+
const transient = error instanceof HaystackApiError && [429, 502, 503, 504].includes(error.status);
|
|
460
|
+
if (!transient || attempt >= pauses.length)
|
|
461
|
+
throw normalModeFailure(error);
|
|
462
|
+
await new Promise(resolve => setTimeout(resolve, pauses[attempt]));
|
|
43
463
|
}
|
|
44
|
-
// The base belongs to the head being verified, which may be an explicit
|
|
45
|
-
// --head rather than the working tree.
|
|
46
|
-
base = git(['merge-base', head, defaultBranch], `Could not find the merge base of ${head} with ${defaultBranch}; fetch origin or pass --base.`);
|
|
47
464
|
}
|
|
48
|
-
|
|
465
|
+
const crawl = acknowledgment.crawl;
|
|
466
|
+
const before = found === null ? 'No crawl existed for this change' : staleWords(found.view, found.stale);
|
|
467
|
+
if (crawl === undefined) {
|
|
468
|
+
return { runId: null, line: `${before}, and the service did not say whether it started ${found === null ? 'one' : 'another'}.` };
|
|
469
|
+
}
|
|
470
|
+
if (crawl.status === 'unavailable') {
|
|
471
|
+
return { runId: null, line: `${before}, and ${found === null ? 'none' : 'another'} could not be started: ${crawlUnavailableText(crawl.reason)}.` };
|
|
472
|
+
}
|
|
473
|
+
if (found === null) {
|
|
474
|
+
return { runId: crawl.runId, line: crawl.status === 'queued'
|
|
475
|
+
? `No crawl existed for this change yet; started one (${crawl.runId}).`
|
|
476
|
+
: `The service already had a crawl for this change (${crawl.runId}).` };
|
|
477
|
+
}
|
|
478
|
+
if (found.stale !== 'retitled' && crawl.runId === found.view.runId) {
|
|
479
|
+
return { runId: crawl.runId, line: `${before}; asked the service to run it again.` };
|
|
480
|
+
}
|
|
481
|
+
return { runId: crawl.runId, line: crawl.status === 'queued'
|
|
482
|
+
? `${before}; started one for this change (${crawl.runId}).`
|
|
483
|
+
: `${before}; the service already had one for this change (${crawl.runId}).` };
|
|
49
484
|
}
|
|
50
485
|
export async function verifyCommand(options) {
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
486
|
+
const everyMs = intervalMs(options.interval);
|
|
487
|
+
// In --json mode stdout carries the one document; every other line goes to stderr.
|
|
488
|
+
const note = (line) => { if (options.json)
|
|
489
|
+
console.error(line);
|
|
490
|
+
else
|
|
491
|
+
console.log(line); };
|
|
492
|
+
const capture = await captureCheckout(Date.now() + EXPLICIT_WALL_MS, repositoryOverride(options.repo));
|
|
493
|
+
const request = capture.derivation.request;
|
|
494
|
+
if (!request.crawl) {
|
|
495
|
+
note(`Nothing to verify: this checkout has no changes against ${request.baseCommit.slice(0, 12)}.`);
|
|
496
|
+
if (options.json)
|
|
497
|
+
process.stdout.write(`${JSON.stringify(withSchema('verify', { crawl: null }), null, 2)}\n`);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const identity = {
|
|
501
|
+
repository: `${request.owner}/${request.repository}`,
|
|
502
|
+
baseCommit: request.baseCommit,
|
|
503
|
+
workCommit: request.workCommit,
|
|
504
|
+
treeSha: request.crawl.treeSha,
|
|
505
|
+
changeTitle: request.crawl.changeTitle,
|
|
506
|
+
};
|
|
507
|
+
const auth = await resolveAuthContext({ preferredLogin: options.account, owner: request.owner, repo: request.repository });
|
|
508
|
+
let view = await readCrawl(identity, auth.token);
|
|
509
|
+
const stale = view === null ? null : staleCrawl(view, identity);
|
|
510
|
+
if (view === null || stale !== null) {
|
|
511
|
+
const found = view === null || stale === null ? null : { view, stale };
|
|
512
|
+
const submitted = await submitCapture(capture, auth.token, found);
|
|
513
|
+
note(submitted.line);
|
|
514
|
+
if (submitted.runId === null) {
|
|
515
|
+
if (options.json)
|
|
516
|
+
process.stdout.write(`${JSON.stringify(withSchema('verify', { crawl: null }), null, 2)}\n`);
|
|
517
|
+
process.exitCode = 1;
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
view = await readCrawlRun(submitted.runId, identity, auth.token);
|
|
521
|
+
if (found !== null && found.stale !== 'retitled' && view.runId === found.view.runId && TERMINAL.has(view.status)) {
|
|
522
|
+
note('The service did not run it again; its last result follows.');
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (options.wait !== false && !TERMINAL.has(view.status)) {
|
|
526
|
+
view = await waitForCrawl(view, identity, auth.token, everyMs);
|
|
527
|
+
}
|
|
528
|
+
if (options.json)
|
|
529
|
+
process.stdout.write(`${JSON.stringify(withSchema('verify', { crawl: view }), null, 2)}\n`);
|
|
530
|
+
else
|
|
531
|
+
console.log(formatCrawl(view));
|
|
532
|
+
const exitCode = crawlExitCode(view);
|
|
533
|
+
if (exitCode !== 0)
|
|
534
|
+
process.exitCode = exitCode;
|
|
64
535
|
}
|