premanmcp 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/verify.js ADDED
@@ -0,0 +1,701 @@
1
+ /**
2
+ * `preman verify` — run the endpoint suite against the developer's own app.
3
+ *
4
+ * This is the local half of the loop. Because the target is loopback and therefore
5
+ * disposable, mutating verbs are allowed here in a way they can never be against
6
+ * production. Results upload to the Pulse run store, which is the same store
7
+ * `preman status` reads, so a local run is visible to the whole team.
8
+ *
9
+ * There are two ways to run. When the files a push touches can be determined,
10
+ * the backend scans them, diffs them against the saved baseline, and returns
11
+ * real generated cases — so the push that changes an endpoint is the push that
12
+ * tests it, including the endpoint that is too new to be in the inventory. When
13
+ * that is not possible (not a git repo, no diff, backend older than this CLI)
14
+ * it falls back to an availability sweep of the saved inventory. Degrading is
15
+ * the point: a hook that tests less is fine, a hook that fails is not.
16
+ *
17
+ * In `--pre-push` mode this command is advisory: every failure path exits 0
18
+ * unless the repository has explicitly opted into blocking.
19
+ */
20
+
21
+ import { readFileSync } from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+
25
+ import { changedFilesForPush, readChangedFiles, readPushRefsFromStdin, repoRoot } from "./changed.js";
26
+ import { resolveLocalTarget } from "./detect.js";
27
+ import { createReporter } from "./progress.js";
28
+ import { backendUrl, callBackendJson, cliInvocation, makeArgs, resolveApiKey } from "./shared.js";
29
+
30
+ export const VERIFY_HELP = `
31
+ Verify options:
32
+ --pre-push Advisory mode: always exit 0, used by the git hook
33
+ --port <port> Add an explicit candidate port for the local target
34
+ --allow-writes Include mutating methods (default on for loopback targets)
35
+ --read-only Never send a mutating request, even locally
36
+ --all Sweep the whole saved inventory instead of the push diff
37
+ --block-on-breaking Exit non-zero when the push breaks a changed endpoint
38
+ --timeout <seconds> Overall wall-clock budget. Defaults to 120
39
+ --json Print the machine-readable result
40
+ `;
41
+
42
+ const DEFAULT_TIMEOUT_SECONDS = 120;
43
+ const REQUEST_TIMEOUT_MS = 10_000;
44
+ const CONCURRENCY = 6;
45
+ const READ_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
46
+ // Never fired automatically even against loopback: a destructive verb that
47
+ // happens to point at a real datastore is not made safe by the port being local.
48
+ const DESTRUCTIVE_METHODS = new Set(["DELETE"]);
49
+
50
+ // Team-level opt-in, committed to the repo so the decision travels with it.
51
+ const REPO_CONFIG = ".preman.json";
52
+
53
+ // A deliberate block, and nothing else, uses this code. Node exits 1 on an
54
+ // uncaught exception, so reusing 1 would turn any CLI crash into a blocked
55
+ // push — the exact failure the advisory guarantee exists to prevent.
56
+ export const BLOCK_EXIT_CODE = 97;
57
+
58
+ function nowMs() {
59
+ return Number(process.hrtime.bigint() / 1000000n);
60
+ }
61
+
62
+ function originLabel() {
63
+ const raw = `${os.hostname() || "local"}`.toLowerCase();
64
+ const cleaned = raw.replace(/[^a-z0-9._-]/g, "-").replace(/^[^a-z0-9]+/, "");
65
+ return (cleaned || "local").slice(0, 64);
66
+ }
67
+
68
+ function fillPath(template) {
69
+ return String(template || "/").replace(/\{[^}]*\}/g, "1");
70
+ }
71
+
72
+ /** Availability case per endpoint. Grading happens server-side; this records what
73
+ * we observed and only classifies transport-level outcomes locally. */
74
+ async function runCase(baseUrl, endpoint, { batchId }) {
75
+ const method = String(endpoint.method || "GET").toUpperCase();
76
+ const target = `${baseUrl}${fillPath(endpoint.path_template || endpoint.path || "/")}`;
77
+ const controller = new AbortController();
78
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
79
+ const startedAt = new Date().toISOString();
80
+ const t0 = nowMs();
81
+
82
+ try {
83
+ const resp = await fetch(target, {
84
+ method,
85
+ signal: controller.signal,
86
+ redirect: "manual",
87
+ headers: { Accept: "application/json" },
88
+ });
89
+ const body = await resp.text();
90
+ return {
91
+ client_run_id: `${batchId}:${method}:${endpoint.path_template || endpoint.path}`,
92
+ method,
93
+ url: target,
94
+ status: resp.status >= 500 ? "failed" : "passed",
95
+ http_status: resp.status,
96
+ latency_ms: nowMs() - t0,
97
+ response_body: body.slice(0, 4000) || null,
98
+ started_at: startedAt,
99
+ };
100
+ } catch (error) {
101
+ const aborted = error?.name === "AbortError";
102
+ return {
103
+ client_run_id: `${batchId}:${method}:${endpoint.path_template || endpoint.path}`,
104
+ method,
105
+ url: target,
106
+ status: "errored",
107
+ http_status: null,
108
+ latency_ms: nowMs() - t0,
109
+ error_message: aborted ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : String(error?.message || error),
110
+ started_at: startedAt,
111
+ };
112
+ } finally {
113
+ clearTimeout(timer);
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Grade a response against the case's own expectation.
119
+ *
120
+ * The sweep can only ask "did it 5xx", which passes a breaking change that
121
+ * correctly answers 422 — the single most common shape of an API regression. A
122
+ * generated case states what it expects, so the same 422 fails on the happy
123
+ * path and passes on the missing-field case.
124
+ */
125
+ export function gradeCase(expect, { status, bodyText }) {
126
+ if (!status) return { ok: false, why: "no response" };
127
+ const rules = expect || {};
128
+
129
+ if (Array.isArray(rules.status_in) && rules.status_in.length) {
130
+ const allowed = rules.status_in.map(Number);
131
+ if (!allowed.includes(status)) {
132
+ return { ok: false, why: `expected ${allowed.join("/")}, got ${status}` };
133
+ }
134
+ } else if (rules.status_not != null) {
135
+ if (status === Number(rules.status_not)) return { ok: false, why: `got ${status}` };
136
+ } else if (status >= 500) {
137
+ return { ok: false, why: `got ${status}` };
138
+ }
139
+
140
+ if (Array.isArray(rules.required_fields) && rules.required_fields.length) {
141
+ let parsed = null;
142
+ try {
143
+ parsed = JSON.parse(bodyText || "");
144
+ } catch {
145
+ return { ok: false, why: "response was not JSON" };
146
+ }
147
+ const subject = Array.isArray(parsed) ? parsed[0] : parsed;
148
+ const missing = rules.required_fields.filter(
149
+ (field) => !subject || !Object.prototype.hasOwnProperty.call(subject, field)
150
+ );
151
+ if (missing.length) return { ok: false, why: `missing ${missing.join(", ")}` };
152
+ }
153
+
154
+ return { ok: true, why: "" };
155
+ }
156
+
157
+ /** Send one generated case and grade it. */
158
+ async function runGeneratedCase(testCase, { batchId, endpoint }) {
159
+ const method = String(testCase.method || "GET").toUpperCase();
160
+ const controller = new AbortController();
161
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
162
+ const startedAt = new Date().toISOString();
163
+ const t0 = nowMs();
164
+
165
+ const headers = { Accept: "application/json", ...(testCase.headers || {}) };
166
+ const body = testCase.body == null ? undefined : testCase.body;
167
+ if (body !== undefined && !Object.keys(headers).some((k) => k.toLowerCase() === "content-type")) {
168
+ headers["Content-Type"] = "application/json";
169
+ }
170
+
171
+ try {
172
+ const resp = await fetch(testCase.url, {
173
+ method,
174
+ headers,
175
+ body,
176
+ signal: controller.signal,
177
+ redirect: "manual",
178
+ });
179
+ const text = await resp.text();
180
+ const verdict = gradeCase(testCase.expect, { status: resp.status, bodyText: text });
181
+ return {
182
+ client_run_id: `${batchId}:${testCase.id || `${method}:${testCase.url}`}`,
183
+ case_kind: testCase.kind,
184
+ case_name: testCase.name,
185
+ endpoint: `${endpoint.method} ${endpoint.path}`,
186
+ method,
187
+ url: testCase.url,
188
+ status: verdict.ok ? "passed" : "failed",
189
+ http_status: resp.status,
190
+ latency_ms: nowMs() - t0,
191
+ response_body: text.slice(0, 4000) || null,
192
+ error_message: verdict.ok ? undefined : verdict.why,
193
+ started_at: startedAt,
194
+ };
195
+ } catch (error) {
196
+ const aborted = error?.name === "AbortError";
197
+ return {
198
+ client_run_id: `${batchId}:${testCase.id || `${method}:${testCase.url}`}`,
199
+ case_kind: testCase.kind,
200
+ case_name: testCase.name,
201
+ endpoint: `${endpoint.method} ${endpoint.path}`,
202
+ method,
203
+ url: testCase.url,
204
+ status: "errored",
205
+ http_status: null,
206
+ latency_ms: nowMs() - t0,
207
+ error_message: aborted ? `timed out after ${REQUEST_TIMEOUT_MS}ms` : String(error?.message || error),
208
+ started_at: startedAt,
209
+ };
210
+ } finally {
211
+ clearTimeout(timer);
212
+ }
213
+ }
214
+
215
+ async function runPool(items, worker, limit) {
216
+ const results = [];
217
+ let cursor = 0;
218
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
219
+ while (cursor < items.length) {
220
+ const index = cursor++;
221
+ results[index] = await worker(items[index]);
222
+ }
223
+ });
224
+ await Promise.all(runners);
225
+ return results;
226
+ }
227
+
228
+ /**
229
+ * `callBackendJson` rejects on transport failure rather than returning a result,
230
+ * so an offline backend would throw straight past the skip handling and exit
231
+ * non-zero. Every backend call in this command goes through here.
232
+ */
233
+ async function safeCall(args, method, routePath, options) {
234
+ try {
235
+ return await callBackendJson(args, method, routePath, options);
236
+ } catch (error) {
237
+ return { ok: false, status_code: 0, detail: String(error?.message || error), transport_error: true };
238
+ }
239
+ }
240
+
241
+ async function fetchInventory(args, token) {
242
+ const result = await safeCall(args, "POST", "/mcp/call-tool", {
243
+ token,
244
+ json: { tool: "get_endpoints", arguments: { format: "json" } },
245
+ });
246
+ if (!result.ok) return { endpoints: [], error: result.detail || `status ${result.status_code}` };
247
+
248
+ const payload = result.result ?? result.data ?? result;
249
+ const endpoints =
250
+ payload?.endpoints ||
251
+ payload?.result?.endpoints ||
252
+ (Array.isArray(payload) ? payload : []) ||
253
+ [];
254
+ return { endpoints: Array.isArray(endpoints) ? endpoints : [] };
255
+ }
256
+
257
+ function selectMethods(args) {
258
+ if (args.has("--read-only")) return { writes: false, reason: "--read-only" };
259
+ return { writes: true, reason: args.has("--allow-writes") ? "--allow-writes" : "loopback target" };
260
+ }
261
+
262
+ /**
263
+ * Whether a broken contract should stop this push.
264
+ *
265
+ * Default is no. The repo-level file is the intended control: blocking is a
266
+ * team decision, so it belongs in a committed file rather than in one
267
+ * developer's environment. The environment variable switches it both ways
268
+ * regardless, because the alternative is incoherent -- a developer who needs
269
+ * out today can already set PREMAN_SKIP_HOOK=1, and that is the bigger hammer:
270
+ * it skips the checks entirely instead of running them and reporting.
271
+ */
272
+ const BLOCKING_ON = ["1", "true", "yes", "on"];
273
+ const BLOCKING_OFF = ["0", "false", "no", "off"];
274
+
275
+ /** Whether the repository committed to blocking. */
276
+ function repoOptedIn(root) {
277
+ if (!root) return false;
278
+ try {
279
+ const config = JSON.parse(readFileSync(path.join(root, REPO_CONFIG), "utf8"));
280
+ return config?.blockOnBreaking === true;
281
+ } catch {
282
+ // No repo config, or malformed. Advisory stands.
283
+ return false;
284
+ }
285
+ }
286
+
287
+ export function blockOnBreaking(args, root) {
288
+ if (args.has("--block-on-breaking")) return { blocking: true, from: "--block-on-breaking" };
289
+
290
+ // Trimmed because this arrives from a .env line or a CI YAML value as often
291
+ // as from a shell, and " off" meaning something other than "off" is a
292
+ // surprise nobody would debug quickly.
293
+ const env = String(process.env.PREMAN_BLOCK_ON_BREAKING || "").trim().toLowerCase();
294
+ if (BLOCKING_ON.includes(env)) return { blocking: true, from: "PREMAN_BLOCK_ON_BREAKING" };
295
+ if (BLOCKING_OFF.includes(env)) {
296
+ // `from` stays null because nothing ends up blocking. `overrode` carries
297
+ // the one thing worth saying out loud -- that a gate the team committed to
298
+ // was switched off from an environment -- and stays null when there was no
299
+ // gate to switch off, so the report does not warn about nothing.
300
+ return { blocking: false, from: null, overrode: repoOptedIn(root) ? REPO_CONFIG : null };
301
+ }
302
+ if (repoOptedIn(root)) return { blocking: true, from: REPO_CONFIG };
303
+ return { blocking: false, from: null };
304
+ }
305
+
306
+ /**
307
+ * Ask the backend what this push changed and which cases to run.
308
+ *
309
+ * Returns null whenever a plan cannot be produced, which the caller reads as
310
+ * "fall back to the sweep". A backend that predates this route answers 404, and
311
+ * that must not be fatal — the CLI updates independently of the API.
312
+ */
313
+ async function fetchPlan(args, token, { root, refs }) {
314
+ const { files: changedNames, reason } = changedFilesForPush(refs, { cwd: root });
315
+ if (!changedNames.length) return { plan: null, reason: reason || "no_changed_files" };
316
+
317
+ const { files } = readChangedFiles(changedNames, { root });
318
+ if (!files.length) return { plan: null, reason: "no_scannable_files", changed: changedNames.length };
319
+
320
+ const result = await safeCall(args, "POST", "/cli/prepush-plan", { token, json: { files } });
321
+ if (!result.ok) {
322
+ return { plan: null, reason: result.status_code === 404 ? "plan_unsupported" : "plan_unavailable" };
323
+ }
324
+ return { plan: result, reason: null, changed: changedNames.length };
325
+ }
326
+
327
+ export async function verifyCommand(commandArgs = []) {
328
+ const args = makeArgs(commandArgs);
329
+ const advisory = args.has("--pre-push");
330
+ if (!advisory) return runVerify(args);
331
+
332
+ // The advisory guarantee cannot depend on having enumerated every failure mode,
333
+ // so an unexpected throw is caught here too.
334
+ try {
335
+ return await runVerify(args);
336
+ } catch (error) {
337
+ const message = `[preman] skipped: ${String(error?.message || error)}`;
338
+ if (args.has("--json")) {
339
+ process.stdout.write(`${JSON.stringify({ state: "skipped", reason: "unexpected_error", message, exitCode: 0 }, null, 2)}\n`);
340
+ } else {
341
+ process.stderr.write(`${message}\n`);
342
+ }
343
+ return { state: "skipped", reason: "unexpected_error", exitCode: 0 };
344
+ }
345
+ }
346
+
347
+ /** A worker pool that tells each worker which display line it owns. */
348
+ async function runSlottedPool(items, worker, limit) {
349
+ const results = [];
350
+ let cursor = 0;
351
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async (_unused, slot) => {
352
+ while (cursor < items.length) {
353
+ const index = cursor++;
354
+ results[index] = await worker(items[index], slot);
355
+ }
356
+ });
357
+ await Promise.all(runners);
358
+ return results;
359
+ }
360
+
361
+ /** Run the generated cases the backend planned for this push. */
362
+ async function runPlan(
363
+ args,
364
+ { plan, target, policy, token, projectId, deadline, asJson, advisory, root, changedFiles }
365
+ ) {
366
+ const origin = String(plan.base_url || "");
367
+ const retarget = (url) =>
368
+ origin && String(url).startsWith(origin) ? target.url + String(url).slice(origin.length) : url;
369
+
370
+ const work = [];
371
+ let withheld = 0;
372
+ for (const entry of plan.selected) {
373
+ for (const testCase of entry.cases) {
374
+ const method = String(testCase.method || "GET").toUpperCase();
375
+ const mutating = !READ_METHODS.has(method) || testCase.effect === "mutating";
376
+ if (DESTRUCTIVE_METHODS.has(method) || (mutating && !policy.writes)) {
377
+ withheld += 1;
378
+ continue;
379
+ }
380
+ work.push({ entry, testCase: { ...testCase, url: retarget(testCase.url) } });
381
+ }
382
+ }
383
+
384
+ // `undefined` defers to TTY detection. Forcing it on would write cursor
385
+ // escapes into every piped hook log; forcing it off would lose the animation
386
+ // in the terminal, which is the one place it earns its keep. Under --json the
387
+ // per-case lines still print, so they go to stderr: stdout has to hold the
388
+ // result object and nothing else for a caller piping this into a parser.
389
+ const reporter = createReporter({
390
+ stream: asJson ? process.stderr : process.stdout,
391
+ live: asJson ? false : undefined,
392
+ });
393
+ const batchId = `prepush-${Date.now().toString(36)}`;
394
+ const changes = plan.changes || [];
395
+
396
+ if (!asJson) {
397
+ const touched = changes.filter((c) => c.change_type !== "unchanged").length;
398
+ reporter.line(
399
+ `[preman] ${changedFiles} changed file${changedFiles === 1 ? "" : "s"} → ` +
400
+ `${plan.scanned?.endpoints ?? 0} endpoint${plan.scanned?.endpoints === 1 ? "" : "s"} scanned, ` +
401
+ `${touched} changed`
402
+ );
403
+ // Silence here would read as "nothing broke" when the truth is "nothing was
404
+ // read". Say which files went unread, and say it loudest when they were the
405
+ // only thing in the push.
406
+ const unreadable = plan.scanned?.unreadable || [];
407
+ if (unreadable.length) {
408
+ const kinds = unreadable.join(", ");
409
+ reporter.line(
410
+ (plan.scanned?.endpoints ?? 0) === 0
411
+ ? `[preman] no endpoints read — ${kinds} ${unreadable.length === 1 ? "is" : "are"} not supported yet, so this push was not checked`
412
+ : `[preman] ${kinds} skipped — not supported yet, so any endpoints there went unchecked`
413
+ );
414
+ }
415
+ reporter.line(
416
+ `[preman] ${target.url} · ${plan.selected.length} endpoint${plan.selected.length === 1 ? "" : "s"}, ` +
417
+ `${work.length} case${work.length === 1 ? "" : "s"}${policy.writes ? "" : ", read-only"}` +
418
+ (plan.traffic?.available ? ", ranked by production traffic" : "")
419
+ );
420
+ }
421
+
422
+ const runs = await runSlottedPool(
423
+ work,
424
+ async ({ entry, testCase }, slot) => {
425
+ if (Date.now() > deadline) return null;
426
+ const label = `${entry.method} ${entry.path} · ${testCase.kind}`;
427
+ reporter.busy(slot, label);
428
+ const result = await runGeneratedCase(testCase, { batchId, endpoint: entry });
429
+ const traffic = entry.traffic?.observations
430
+ ? `${entry.traffic.observations} prod calls/7d`
431
+ : "";
432
+ reporter.done(slot, {
433
+ ok: result.status === "passed",
434
+ label,
435
+ detail: result.status === "passed" ? traffic : result.error_message || "",
436
+ });
437
+ return { ...result, change_type: entry.change_type, reason: entry.reason };
438
+ },
439
+ CONCURRENCY
440
+ );
441
+ reporter.stop();
442
+
443
+ const completed = runs.filter(Boolean);
444
+ const failed = completed.filter((r) => r.status !== "passed");
445
+ // Breaking means the contract broke, not merely that some probe was unhappy:
446
+ // the happy path of an endpoint this push actually changed.
447
+ const breaking = failed.filter(
448
+ (r) => r.case_kind === "happy_path" && r.reason === "changed_in_push"
449
+ );
450
+ const removed = changes.filter((c) => c.change_type === "removed");
451
+
452
+ let uploaded = 0;
453
+ if (projectId && completed.length) {
454
+ const upload = await safeCall(args, "POST", `/projects/${projectId}/api-runs`, {
455
+ token,
456
+ json: {
457
+ origin: "local",
458
+ origin_label: originLabel(),
459
+ source: "ci",
460
+ batch_id: batchId,
461
+ batch_label: "pre-push verify",
462
+ runs: completed,
463
+ },
464
+ });
465
+ if (upload.ok) uploaded = Number(upload.inserted || 0);
466
+ }
467
+
468
+ const gate = blockOnBreaking(args, root);
469
+ const shouldBlock = gate.blocking && (breaking.length > 0 || removed.length > 0);
470
+
471
+ if (!asJson) {
472
+ if (failed.length) {
473
+ reporter.line(`[preman] ${failed.length} of ${completed.length} cases failed`);
474
+ } else {
475
+ reporter.line(`[preman] all ${completed.length} cases passed`);
476
+ }
477
+ for (const change of removed.slice(0, 5)) {
478
+ reporter.line(` ! ${change.method} ${change.path} was removed by this push`);
479
+ }
480
+ if (withheld) reporter.line(`[preman] ${withheld} mutating case${withheld === 1 ? "" : "s"} withheld`);
481
+ if (uploaded) reporter.line(`[preman] uploaded ${uploaded} run${uploaded === 1 ? "" : "s"}`);
482
+ if (shouldBlock) {
483
+ reporter.line(`[preman] blocking this push (${gate.from}). Set PREMAN_SKIP_HOOK=1 to override.`);
484
+ } else if (breaking.length || removed.length) {
485
+ // Tracks what the gate is armed against rather than the breaking count
486
+ // alone. A push that only removes an endpoint is one the gate would have
487
+ // stopped, so bypassing it there has to be as visible as anywhere else.
488
+ const found = [
489
+ breaking.length && `${breaking.length} breaking change${breaking.length === 1 ? "" : "s"}`,
490
+ removed.length && `${removed.length} removal${removed.length === 1 ? "" : "s"}`,
491
+ ]
492
+ .filter(Boolean)
493
+ .join(" and ");
494
+ const disarmed = gate.overrode
495
+ ? ` (${gate.overrode} asked to block; PREMAN_BLOCK_ON_BREAKING switched it off)`
496
+ : "";
497
+ reporter.line(`[preman] ${found} — advisory only${disarmed}`);
498
+ }
499
+ }
500
+
501
+ return {
502
+ state: failed.length ? "failed" : "passed",
503
+ mode: "plan",
504
+ target: target.url,
505
+ changed_files: changedFiles,
506
+ endpoints: plan.selected.length,
507
+ verified: completed.length,
508
+ skipped: work.length - completed.length,
509
+ withheld,
510
+ breaking: breaking.map((r) => ({ endpoint: r.endpoint, http_status: r.http_status, error: r.error_message })),
511
+ removed: removed.map((c) => ({ method: c.method, path: c.path })),
512
+ failures: failed.map((r) => ({
513
+ endpoint: r.endpoint,
514
+ case: r.case_kind,
515
+ method: r.method,
516
+ url: r.url,
517
+ http_status: r.http_status,
518
+ error: r.error_message || null,
519
+ })),
520
+ uploaded,
521
+ // Advisory unless the repository opted in. `advisory` only flattens an
522
+ // unexpected throw; a deliberate block has to survive it.
523
+ exitCode: shouldBlock ? BLOCK_EXIT_CODE : 0,
524
+ blocked: shouldBlock,
525
+ message: null,
526
+ };
527
+ }
528
+
529
+ async function runVerify(args) {
530
+ const advisory = args.has("--pre-push");
531
+ const asJson = args.has("--json");
532
+ const budgetMs =
533
+ Math.max(5, Number.parseInt(args.value("--timeout", String(DEFAULT_TIMEOUT_SECONDS)), 10) || DEFAULT_TIMEOUT_SECONDS) *
534
+ 1000;
535
+ const deadline = Date.now() + budgetMs;
536
+
537
+ const finish = (outcome) => {
538
+ if (asJson) process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
539
+ else if (outcome.message) process.stdout.write(`${outcome.message}\n`);
540
+ // A deliberate block is the one non-zero code advisory mode does not flatten.
541
+ if ((!advisory || outcome.blocked) && outcome.exitCode) process.exitCode = outcome.exitCode;
542
+ return outcome;
543
+ };
544
+
545
+ const token = resolveApiKey(args);
546
+ if (!token) {
547
+ return finish({
548
+ state: "skipped",
549
+ reason: "no_credentials",
550
+ message: `[preman] skipped: no API key. Run \`${cliInvocation()} login\`.`,
551
+ exitCode: 0,
552
+ });
553
+ }
554
+
555
+ const status = await safeCall(args, "GET", "/cli/status", { token });
556
+ if (!status.ok) {
557
+ return finish({
558
+ state: "skipped",
559
+ reason: status.transport_error ? "backend_unreachable" : "status_unavailable",
560
+ message: `[preman] skipped: ${backendUrl(args)} ${
561
+ status.transport_error ? `unreachable (${status.detail})` : `returned ${status.status_code}`
562
+ }.`,
563
+ exitCode: 0,
564
+ });
565
+ }
566
+ const projectId = status.workspace?.project_id || null;
567
+
568
+ const root = repoRoot();
569
+ // The plan comes first so a brand-new endpoint — one this push introduces and
570
+ // the saved inventory has never heard of — can still confirm the local target.
571
+ const planned = args.has("--all")
572
+ ? { plan: null, reason: "--all" }
573
+ : await fetchPlan(args, token, { root, refs: advisory ? readPushRefsFromStdin() : [] });
574
+ const planEndpoints = (planned.plan?.selected || []).map((entry) => ({
575
+ method: entry.method,
576
+ path_template: entry.path,
577
+ }));
578
+
579
+ const { endpoints, error } = await fetchInventory(args, token);
580
+ if ((error || !endpoints.length) && !planEndpoints.length) {
581
+ return finish({
582
+ state: "skipped",
583
+ reason: "no_inventory",
584
+ message:
585
+ `[preman] skipped: no saved endpoints to verify` +
586
+ (error ? ` (${error})` : `. Run \`${cliInvocation()} endpoints discover\`.`),
587
+ exitCode: 0,
588
+ });
589
+ }
590
+
591
+ const extraPorts = args.value("--port", "") ? [args.value("--port", "")] : [];
592
+ const resolved = await resolveLocalTarget([...planEndpoints, ...endpoints], {
593
+ extraPorts,
594
+ timeoutMs: 2000,
595
+ });
596
+ if (!resolved.target) {
597
+ return finish({
598
+ state: "skipped",
599
+ reason: "no_local_target",
600
+ message: `[preman] skipped: ${resolved.reason}.`,
601
+ candidates: resolved.candidates?.map((c) => ({ url: c.url, reason: c.reason })) || [],
602
+ exitCode: 0,
603
+ });
604
+ }
605
+
606
+ const target = resolved.target;
607
+ const policy = selectMethods(args);
608
+
609
+ if (planned.plan?.selected?.length) {
610
+ return finish(
611
+ await runPlan(args, {
612
+ plan: planned.plan,
613
+ target,
614
+ policy,
615
+ token,
616
+ projectId,
617
+ deadline,
618
+ asJson,
619
+ advisory,
620
+ root,
621
+ changedFiles: planned.changed || 0,
622
+ })
623
+ );
624
+ }
625
+
626
+ const selected = endpoints.filter((ep) => {
627
+ const method = String(ep.method || "GET").toUpperCase();
628
+ if (READ_METHODS.has(method)) return true;
629
+ if (DESTRUCTIVE_METHODS.has(method)) return false;
630
+ return policy.writes;
631
+ });
632
+
633
+ if (!asJson) {
634
+ process.stdout.write(
635
+ `[preman] target ${target.url} (${target.confidence} match, ${target.matched}/${target.expected} routes via ${target.via})\n` +
636
+ `[preman] verifying ${selected.length} endpoint${selected.length === 1 ? "" : "s"}` +
637
+ `${policy.writes ? ", writes enabled" : ", read-only"}\n`
638
+ );
639
+ }
640
+
641
+ const batchId = `prepush-${Date.now().toString(36)}`;
642
+ const runs = await runPool(
643
+ selected,
644
+ (endpoint) =>
645
+ Date.now() > deadline
646
+ ? null
647
+ : runCase(target.url, endpoint, { batchId }),
648
+ CONCURRENCY
649
+ );
650
+ const completed = runs.filter(Boolean);
651
+ const failed = completed.filter((r) => r.status !== "passed");
652
+
653
+ let uploaded = 0;
654
+ if (projectId && completed.length) {
655
+ const upload = await safeCall(args, "POST", `/projects/${projectId}/api-runs`, {
656
+ token,
657
+ json: {
658
+ origin: "local",
659
+ origin_label: originLabel(),
660
+ source: "ci",
661
+ batch_id: batchId,
662
+ batch_label: "pre-push verify",
663
+ runs: completed,
664
+ },
665
+ });
666
+ if (upload.ok) uploaded = Number(upload.inserted || 0);
667
+ }
668
+
669
+ const outcome = {
670
+ state: failed.length ? "failed" : "passed",
671
+ target: target.url,
672
+ verified: completed.length,
673
+ skipped: selected.length - completed.length,
674
+ failures: failed.map((r) => ({
675
+ method: r.method,
676
+ url: r.url,
677
+ http_status: r.http_status,
678
+ error: r.error_message || null,
679
+ })),
680
+ uploaded,
681
+ // Advisory by construction: a real failure still reports zero so the push
682
+ // proceeds. The report is the product, not the gate.
683
+ exitCode: 0,
684
+ };
685
+
686
+ if (!asJson) {
687
+ if (failed.length) {
688
+ process.stdout.write(`[preman] ${failed.length} of ${completed.length} failed:\n`);
689
+ for (const failure of failed.slice(0, 10)) {
690
+ process.stdout.write(
691
+ ` ✗ ${failure.method} ${failure.url} ${failure.http_status || failure.error || ""}\n`
692
+ );
693
+ }
694
+ } else {
695
+ process.stdout.write(`[preman] all ${completed.length} endpoints healthy\n`);
696
+ }
697
+ if (uploaded) process.stdout.write(`[preman] uploaded ${uploaded} run${uploaded === 1 ? "" : "s"}\n`);
698
+ }
699
+
700
+ return finish({ ...outcome, message: null });
701
+ }