@sackville-mcp/cli 0.0.1-alpha.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.
@@ -0,0 +1,2752 @@
1
+ import { parseArgs } from "node:util";
2
+ import { detectInstalledVersion, getDoc, listVersions, openDb, resolveVersion, searchDocs } from "@sackville-mcp/core";
3
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { ArtifactStore, Redactor, importToCollection, isGraphqlEnvelope, loadCollection, resolveSecretStore, runRequest, runRequestForContract, runRequestToHar, runSequence, validateCapturedTraffic, validateGraphqlOperation, validateOpenApiRequest, validateOpenApiResponse } from "@sackville-mcp/api";
7
+ import { tmpdir } from "node:os";
8
+ import { ArtifactStore as ArtifactStore$1, BrowserGate, BrowserManager, PageDriver, auditA11y, createSsrfProxy, driveBrowserFlowToHar, engineLauncher, loadFlow, resolveEngine, runFlow } from "@sackville-mcp/browser";
9
+ import { Redactor as Redactor$1, resolveAndPin } from "@sackville-mcp/safety";
10
+ import { CoverageGateError, coveragePyToIstanbul, runScoped, runScopedPython, uncoveredInDiff } from "@sackville-mcp/coverage";
11
+ import { CHANGELOG_FILENAMES, auditDependency, changedDependencies, comparatorFor, dependencyNames, gemRepoUrl, githubOwnerRepo, loadOsvSnapshot, matchName, normalizePypiName, npmRepoUrl, pypiJsonToPackument, pypiRepoUrl, rubygemsToPackument, sliceChangelog } from "@sackville-mcp/deps";
12
+ import { FlakeGateError, HistoryStore, Quarantine, QuarantineGateError, quarantineCandidates, runAndRecord, runAndRecordPytest } from "@sackville-mcp/flake";
13
+ import { LanguageServerManager, LspGateError, LspQueryEngine, LspRenameEngine, defaultListFiles, parseServerRegistry } from "@sackville-mcp/lsp";
14
+ import { MutateGateError, parseMutmutResults, runCosmicRay, runMutation, runMutmut, summarizeMutation } from "@sackville-mcp/mutate";
15
+ import { ArtifactStore as ArtifactStore$2 } from "@sackville-mcp/artifacts";
16
+ import { composeVerdict, fromContractResults, fromDependencyAudits, fromDiffCoverage, fromFlakeVerdicts, fromMutationSummary } from "@sackville-mcp/verdict";
17
+ import { orchestrate } from "@sackville-mcp/verify";
18
+ //#region src/api.ts
19
+ /**
20
+ * Load an operator-supplied custom-scalar coercer module (ADR 0018 slice 6). The human is the
21
+ * operator, so a real module path is permitted. Returns the coercer record, or `undefined` on
22
+ * ANY failure (missing/throwing module, or a non-object default export) AFTER writing a loud
23
+ * error — the caller MUST then exit non-zero, never silently fall back to no coercers (which
24
+ * would look clean — an absence-laundering hazard).
25
+ */
26
+ async function loadCoercers(file, io) {
27
+ let exported;
28
+ try {
29
+ exported = (await import(pathToFileURL(resolve(file)).href)).default;
30
+ } catch (err) {
31
+ io.err(`api: failed to load --coercers module ${file}: ${err.message}\n`);
32
+ return;
33
+ }
34
+ if (!exported || typeof exported !== "object") {
35
+ io.err(`api: --coercers module ${file} must default-export an object of scalar coercers\n`);
36
+ return;
37
+ }
38
+ return exported;
39
+ }
40
+ /** Matches `{{secret:NAME}}` references; captures the NAME only (never a value). */
41
+ const SECRET_RE = /\{\{\s*secret:\s*([^}\s]+)\s*\}\}/g;
42
+ /** Collect sorted, unique secret NAMES referenced anywhere in a string. */
43
+ function secretNames(...sources) {
44
+ const names = /* @__PURE__ */ new Set();
45
+ for (const s of sources) {
46
+ if (!s) continue;
47
+ for (const m of s.matchAll(SECRET_RE)) if (m[1]) names.add(m[1]);
48
+ }
49
+ return [...names].sort();
50
+ }
51
+ /** Replace `{{secret:NAME}}` references with a `[secret:NAME]` placeholder. */
52
+ function maskSecrets(s) {
53
+ return s.replace(SECRET_RE, (_m, name) => `[secret:${name}]`);
54
+ }
55
+ /** Parse repeatable `--var k=v` flags (split on the FIRST `=`) into a record. */
56
+ function parseVars$2(raw) {
57
+ const vars = {};
58
+ for (const item of raw ?? []) {
59
+ const eq = item.indexOf("=");
60
+ if (eq === -1) vars[item] = "";
61
+ else vars[item.slice(0, eq)] = item.slice(eq + 1);
62
+ }
63
+ return vars;
64
+ }
65
+ async function runApi(args, io) {
66
+ const [sub, ...rest] = args;
67
+ switch (sub) {
68
+ case "list": return cmdList(rest, io);
69
+ case "get": return cmdGet$1(rest, io);
70
+ case "run": return cmdRun$3(rest, io);
71
+ case "run-collection": return cmdRunCollection(rest, io);
72
+ case "validate": return await cmdValidate(rest, io);
73
+ case "validate-request": return cmdValidateRequest(rest, io);
74
+ case "validate-capture": return cmdValidateCapture(rest, io);
75
+ case "import": return cmdImport(rest, io);
76
+ default:
77
+ io.err(`unknown api subcommand: ${sub ?? "(none)"}\n`);
78
+ return 1;
79
+ }
80
+ }
81
+ function cmdList(args, io) {
82
+ const { values, positionals } = parseArgs({
83
+ args,
84
+ allowPositionals: true,
85
+ options: { json: { type: "boolean" } }
86
+ });
87
+ const dir = positionals[0];
88
+ if (!dir) {
89
+ io.err("api list needs <dir>\n");
90
+ return 1;
91
+ }
92
+ const requests = [...loadCollection(dir).requests.values()].map((e) => ({
93
+ name: e.request.name,
94
+ method: e.request.method,
95
+ url: e.request.url
96
+ }));
97
+ if (values.json) {
98
+ io.out(`${JSON.stringify({ requests }, null, 2)}\n`);
99
+ return 0;
100
+ }
101
+ for (const r of requests) io.out(`${r.method} ${r.name} ${r.url}\n`);
102
+ return 0;
103
+ }
104
+ function cmdGet$1(args, io) {
105
+ const { values, positionals } = parseArgs({
106
+ args,
107
+ allowPositionals: true,
108
+ options: { json: { type: "boolean" } }
109
+ });
110
+ const [dir, name] = positionals;
111
+ if (!dir || !name) {
112
+ io.err("api get needs <dir> <name>\n");
113
+ return 1;
114
+ }
115
+ const entry = loadCollection(dir).requests.get(name);
116
+ if (!entry) {
117
+ io.err(`no request named ${name}\n`);
118
+ return 1;
119
+ }
120
+ const { request } = entry;
121
+ const secrets = secretNames(request.url, ...request.headers.flatMap((h) => [h.name, h.value]), request.body?.content);
122
+ if (values.json) {
123
+ io.out(`${JSON.stringify({
124
+ name: request.name,
125
+ method: request.method,
126
+ url: request.url,
127
+ headers: request.headers,
128
+ requiredSecrets: secrets
129
+ }, null, 2)}\n`);
130
+ return 0;
131
+ }
132
+ io.out(`${request.method} ${maskSecrets(request.url)}\n`);
133
+ for (const h of request.headers) io.out(` ${h.name}: ${maskSecrets(h.value)}\n`);
134
+ io.out(`required secrets: ${secrets.length ? secrets.join(", ") : "(none)"}\n`);
135
+ return 0;
136
+ }
137
+ /** Shared run-option flags for `run` and `run-collection`. */
138
+ const RUN_OPTIONS = {
139
+ var: {
140
+ type: "string",
141
+ multiple: true
142
+ },
143
+ env: { type: "string" },
144
+ unsafe: { type: "boolean" },
145
+ "allow-host": {
146
+ type: "string",
147
+ multiple: true
148
+ },
149
+ "block-private": { type: "boolean" },
150
+ "max-redirects": { type: "string" },
151
+ keyring: { type: "boolean" },
152
+ json: { type: "boolean" }
153
+ };
154
+ /** Parse `--max-redirects` (a non-negative integer) or undefined. */
155
+ function parseMaxRedirects(raw) {
156
+ if (raw === void 0) return void 0;
157
+ const n = Number(raw);
158
+ return Number.isInteger(n) && n >= 0 ? n : void 0;
159
+ }
160
+ /** Secret store for a run: opt into the OS keyring (chained ahead of env) with
161
+ * `--keyring`, else the env default (`SACKVILLE_SECRET_<NAME>`). */
162
+ function secretsFor(keyring) {
163
+ return keyring ? resolveSecretStore({ keyring: true }) : void 0;
164
+ }
165
+ /** Read a stored response body by handle and JSON-parse it; fall back to raw. */
166
+ function parseStoredBody(artifacts, handle) {
167
+ const raw = artifacts.get(handle)?.body ?? "";
168
+ try {
169
+ return JSON.parse(raw);
170
+ } catch {
171
+ return raw;
172
+ }
173
+ }
174
+ /** Redact every finding's message AND path through a redactor that learned the run's
175
+ * resolved secrets — a finding can echo a captured body/param/variable value. */
176
+ function redactContract(raw, secrets) {
177
+ const redactor = new Redactor();
178
+ for (const s of secrets) redactor.register(s.name, s.value);
179
+ return {
180
+ ...raw,
181
+ findings: raw.findings.map((f) => ({
182
+ ...f,
183
+ message: redactor.redact(f.message),
184
+ ...f.path !== void 0 ? { path: redactor.redact(f.path) } : {}
185
+ }))
186
+ };
187
+ }
188
+ function printContract(io, contract, label = "contract") {
189
+ io.out(`${label}: ${contract.valid ? "valid" : "INVALID"}\n`);
190
+ for (const f of contract.findings) io.out(` ${f.severity.toUpperCase()} ${f.kind}: ${f.message}${f.path ? ` (${f.path})` : ""}\n`);
191
+ }
192
+ async function cmdRun$3(args, io) {
193
+ const { values, positionals } = parseArgs({
194
+ args,
195
+ allowPositionals: true,
196
+ options: {
197
+ ...RUN_OPTIONS,
198
+ openapi: { type: "string" },
199
+ graphql: { type: "string" },
200
+ coercers: { type: "string" }
201
+ }
202
+ });
203
+ const [dir, name] = positionals;
204
+ if (!dir || !name) {
205
+ io.err("api run needs <dir> <name>\n");
206
+ return 1;
207
+ }
208
+ const collection = loadCollection(dir);
209
+ if (!collection.requests.has(name)) {
210
+ io.err(`no request named ${name}\n`);
211
+ return 1;
212
+ }
213
+ const artifacts = new ArtifactStore();
214
+ const runOpts = {
215
+ vars: parseVars$2(values.var),
216
+ env: values.env,
217
+ allowUnsafe: values.unsafe ?? false,
218
+ allowedHosts: values["allow-host"],
219
+ allowPrivate: !values["block-private"],
220
+ maxRedirects: parseMaxRedirects(values["max-redirects"]),
221
+ secrets: secretsFor(values.keyring),
222
+ artifacts
223
+ };
224
+ let result;
225
+ let reqCapture;
226
+ if (values.openapi || values.graphql) {
227
+ const driven = await runRequestForContract(collection, name, runOpts);
228
+ result = driven.result;
229
+ reqCapture = driven.capture;
230
+ } else result = await runRequest(collection, name, runOpts);
231
+ let contract;
232
+ let requestContract;
233
+ if (values.openapi) {
234
+ const spec = JSON.parse(readFileSync(values.openapi, "utf8"));
235
+ const baseDir = dirname(values.openapi);
236
+ if (result.sent && result.response) {
237
+ const url = new URL(result.request.url);
238
+ contract = validateOpenApiResponse(spec, {
239
+ method: result.request.method,
240
+ path: url.pathname
241
+ }, {
242
+ status: result.response.status,
243
+ headers: result.response.headers,
244
+ body: parseStoredBody(artifacts, result.response.bodyHandle)
245
+ }, { baseDir });
246
+ }
247
+ if (reqCapture && !isGraphqlEnvelope(reqCapture.request.body)) requestContract = redactContract(validateOpenApiRequest(spec, reqCapture.request, {
248
+ baseDir,
249
+ bodyPresenceAuthoritative: true,
250
+ paramsAuthoritative: true
251
+ }), reqCapture.registeredSecrets);
252
+ }
253
+ let graphqlContract;
254
+ if (values.graphql && reqCapture && isGraphqlEnvelope(reqCapture.request.body)) {
255
+ const sdl = readFileSync(values.graphql, "utf8");
256
+ let scalarCoercers;
257
+ if (values.coercers !== void 0) {
258
+ scalarCoercers = await loadCoercers(values.coercers, io);
259
+ if (scalarCoercers === void 0) return 1;
260
+ }
261
+ const env = reqCapture.request.body;
262
+ graphqlContract = redactContract(validateGraphqlOperation(sdl, env.query, {
263
+ operationName: env.operationName,
264
+ ...scalarCoercers ? { scalarCoercers } : {},
265
+ ...result.sent && result.response ? { json: parseStoredBody(artifacts, result.response.bodyHandle) } : {},
266
+ ...env.variables !== void 0 ? {
267
+ variables: env.variables,
268
+ variablesAuthoritative: true
269
+ } : {}
270
+ }), reqCapture.registeredSecrets);
271
+ }
272
+ const assertionsOk = !!result.response?.assertions.every((a) => a.pass);
273
+ const ok = result.sent && assertionsOk && (contract ? contract.valid : true) && (requestContract ? requestContract.valid : true) && (graphqlContract ? graphqlContract.valid : true);
274
+ if (values.json) {
275
+ io.out(`${JSON.stringify({
276
+ ...result,
277
+ ...contract ? { contract } : {},
278
+ ...requestContract ? { requestContract } : {},
279
+ ...graphqlContract ? { graphqlContract } : {}
280
+ }, null, 2)}\n`);
281
+ return ok ? 0 : 1;
282
+ }
283
+ io.out(`${result.request.method} ${result.request.url}\n`);
284
+ if (result.sent) io.out("sent\n");
285
+ else io.out(`dry-run (not sent)${result.reason ? `: ${result.reason}` : ""}\n`);
286
+ if (result.response) {
287
+ const res = result.response;
288
+ io.out(`status ${res.status} ${res.latencyMs}ms\n`);
289
+ for (const a of res.assertions) io.out(`${a.pass ? "PASS" : "FAIL"} ${a.source} ${a.op}${a.path ? ` ${a.path}` : ""}\n`);
290
+ for (const t of res.scriptTests) io.out(`${t.pass ? "PASS" : "FAIL"} script: ${t.name}${t.error ? ` — ${t.error}` : ""}\n`);
291
+ io.out(`body: ${res.bodyHandle}\n`);
292
+ }
293
+ if (requestContract) printContract(io, requestContract, "request contract");
294
+ if (contract) printContract(io, contract, "response contract");
295
+ if (graphqlContract) printContract(io, graphqlContract, "graphql contract");
296
+ return ok ? 0 : 1;
297
+ }
298
+ async function cmdRunCollection(args, io) {
299
+ const { values, positionals } = parseArgs({
300
+ args,
301
+ allowPositionals: true,
302
+ options: {
303
+ ...RUN_OPTIONS,
304
+ "stop-on-failure": { type: "boolean" }
305
+ }
306
+ });
307
+ const [dir, ...names] = positionals;
308
+ if (!dir || names.length === 0) {
309
+ io.err("api run-collection needs <dir> <name...>\n");
310
+ return 1;
311
+ }
312
+ const artifacts = new ArtifactStore();
313
+ const result = await runSequence(loadCollection(dir), names, {
314
+ vars: parseVars$2(values.var),
315
+ env: values.env,
316
+ allowUnsafe: values.unsafe ?? false,
317
+ allowedHosts: values["allow-host"],
318
+ allowPrivate: !values["block-private"],
319
+ maxRedirects: parseMaxRedirects(values["max-redirects"]),
320
+ secrets: secretsFor(values.keyring),
321
+ stopOnFailure: values["stop-on-failure"] ?? false,
322
+ artifacts
323
+ });
324
+ const ok = result.steps.every((s) => s.result.sent && (s.result.response?.assertions.every((a) => a.pass) ?? false));
325
+ if (values.json) {
326
+ io.out(`${JSON.stringify(result, null, 2)}\n`);
327
+ return ok ? 0 : 1;
328
+ }
329
+ for (const step of result.steps) {
330
+ const res = step.result;
331
+ const status = res.sent ? String(res.response?.status ?? "-") : "dry-run";
332
+ const passed = res.sent && (res.response?.assertions.every((a) => a.pass) ?? false);
333
+ io.out(`${step.name} ${status} ${passed ? "PASS" : "FAIL"}\n`);
334
+ }
335
+ io.out(`captured: ${Object.keys(result.captured).join(", ") || "(none)"}\n`);
336
+ return ok ? 0 : 1;
337
+ }
338
+ const IMPORT_FORMATS = new Set([
339
+ "postman",
340
+ "insomnia",
341
+ "openapi",
342
+ "har"
343
+ ]);
344
+ function cmdImport(args, io) {
345
+ const { values, positionals } = parseArgs({
346
+ args,
347
+ allowPositionals: true,
348
+ options: { name: { type: "string" } }
349
+ });
350
+ const [format, source, dest] = positionals;
351
+ if (!format || !source || !dest) {
352
+ io.err("api import needs <postman|insomnia|openapi|har> <source-file> <dest-dir>\n");
353
+ return 1;
354
+ }
355
+ if (!IMPORT_FORMATS.has(format)) {
356
+ io.err(`unknown import format: ${format} (expected postman|insomnia|openapi|har)\n`);
357
+ return 1;
358
+ }
359
+ const count = importToCollection(format, readFileSync(source, "utf8"), dest, { name: values.name });
360
+ io.out(`imported ${count} request(s) into ${dest}\n`);
361
+ return 0;
362
+ }
363
+ async function cmdValidate(args, io) {
364
+ const { values } = parseArgs({
365
+ args,
366
+ allowPositionals: true,
367
+ options: {
368
+ graphql: { type: "string" },
369
+ query: { type: "string" },
370
+ operation: { type: "string" },
371
+ variables: { type: "string" },
372
+ coercers: { type: "string" },
373
+ json: { type: "boolean" }
374
+ }
375
+ });
376
+ if (!values.graphql || !values.query) {
377
+ io.err("api validate needs --graphql <schemafile> --query <queryfile>\n");
378
+ return 1;
379
+ }
380
+ const sdl = readFileSync(values.graphql, "utf8");
381
+ const query = readFileSync(values.query, "utf8");
382
+ let variables;
383
+ if (values.variables !== void 0) try {
384
+ variables = JSON.parse(values.variables);
385
+ } catch {
386
+ variables = JSON.parse(readFileSync(values.variables, "utf8"));
387
+ }
388
+ let scalarCoercers;
389
+ if (values.coercers !== void 0) {
390
+ scalarCoercers = await loadCoercers(values.coercers, io);
391
+ if (scalarCoercers === void 0) return 1;
392
+ }
393
+ const contract = validateGraphqlOperation(sdl, query, {
394
+ operationName: values.operation,
395
+ ...scalarCoercers ? { scalarCoercers } : {},
396
+ ...variables !== void 0 ? {
397
+ variables,
398
+ variablesAuthoritative: true
399
+ } : {}
400
+ });
401
+ if (values.json) {
402
+ io.out(`${JSON.stringify(contract, null, 2)}\n`);
403
+ return contract.valid ? 0 : 1;
404
+ }
405
+ io.out(`valid: ${contract.valid}\n`);
406
+ for (const f of contract.findings) io.out(` ${f.severity.toUpperCase()} ${f.kind}: ${f.message}${f.path ? ` (${f.path})` : ""}\n`);
407
+ return contract.valid ? 0 : 1;
408
+ }
409
+ /**
410
+ * `api validate-request --openapi <spec.json> --method <M> --path </p> [--body <file>]
411
+ * [--query k=v]…` — a preflight: validate a request's body + params against an OpenAPI
412
+ * operation BEFORE sending it. The human supplies the real request, so body presence
413
+ * and params are authoritative. (A GraphQL envelope is refused, not schema-failed.)
414
+ */
415
+ function cmdValidateRequest(args, io) {
416
+ const { values } = parseArgs({
417
+ args,
418
+ allowPositionals: true,
419
+ options: {
420
+ openapi: { type: "string" },
421
+ method: { type: "string" },
422
+ path: { type: "string" },
423
+ body: { type: "string" },
424
+ query: {
425
+ type: "string",
426
+ multiple: true
427
+ },
428
+ header: {
429
+ type: "string",
430
+ multiple: true
431
+ },
432
+ form: {
433
+ type: "string",
434
+ multiple: true
435
+ },
436
+ "form-file": {
437
+ type: "string",
438
+ multiple: true
439
+ },
440
+ json: { type: "boolean" }
441
+ }
442
+ });
443
+ if (!values.openapi || !values.method || !values.path) {
444
+ io.err("api validate-request needs --openapi <spec.json> --method <M> --path </p>\n");
445
+ return 1;
446
+ }
447
+ const spec = JSON.parse(readFileSync(values.openapi, "utf8"));
448
+ const body = values.body !== void 0 ? JSON.parse(readFileSync(values.body, "utf8")) : void 0;
449
+ if (isGraphqlEnvelope(body)) {
450
+ io.err("the request body is a GraphQL envelope ({query}); use `api validate --graphql` instead\n");
451
+ return 1;
452
+ }
453
+ const query = {};
454
+ for (const kv of values.query ?? []) {
455
+ const eq = kv.indexOf("=");
456
+ if (eq < 0) continue;
457
+ const k = kv.slice(0, eq);
458
+ const v = kv.slice(eq + 1);
459
+ const cur = query[k];
460
+ query[k] = cur === void 0 ? v : Array.isArray(cur) ? [...cur, v] : [cur, v];
461
+ }
462
+ const headers = {};
463
+ for (const hv of values.header ?? []) {
464
+ const c = hv.indexOf(":");
465
+ if (c < 0) continue;
466
+ headers[hv.slice(0, c).trim().toLowerCase()] = hv.slice(c + 1).trim();
467
+ }
468
+ const form = {};
469
+ for (const kv of values.form ?? []) {
470
+ const eq = kv.indexOf("=");
471
+ if (eq < 0) continue;
472
+ const k = kv.slice(0, eq);
473
+ const v = kv.slice(eq + 1);
474
+ const cur = form[k];
475
+ form[k] = cur === void 0 ? v : Array.isArray(cur) ? [...cur, v] : [cur, v];
476
+ }
477
+ const formFileFields = values["form-file"] ?? [];
478
+ const contract = validateOpenApiRequest(spec, {
479
+ method: values.method,
480
+ path: values.path,
481
+ body,
482
+ ...Object.keys(query).length > 0 ? { query } : {},
483
+ ...Object.keys(headers).length > 0 ? { headers } : {},
484
+ ...Object.keys(form).length > 0 ? { form } : {},
485
+ ...formFileFields.length > 0 ? { formFileFields } : {}
486
+ }, {
487
+ baseDir: dirname(values.openapi),
488
+ bodyPresenceAuthoritative: true,
489
+ paramsAuthoritative: true
490
+ });
491
+ if (values.json) {
492
+ io.out(`${JSON.stringify(contract, null, 2)}\n`);
493
+ return contract.valid ? 0 : 1;
494
+ }
495
+ io.out(`valid: ${contract.valid}\n`);
496
+ for (const f of contract.findings) io.out(` ${f.severity.toUpperCase()} ${f.kind}: ${f.message}${f.path ? ` (${f.path})` : ""}\n`);
497
+ return contract.valid ? 0 : 1;
498
+ }
499
+ /**
500
+ * `api validate-capture <har.zip> --openapi <spec.json> | --graphql <schema.graphql>`
501
+ * — validate the traffic in a captured HAR against an OpenAPI and/or GraphQL
502
+ * contract (ADR 0013, the capture→contract bridge). The human is the operator, so
503
+ * the local HAR file is read directly (no surface capture gate). REST entries are
504
+ * checked against the OpenAPI spec; GraphQL entries (at `--graphql-endpoint`,
505
+ * default `/graphql`) are checked against the SDL. Exits 1 when not clean.
506
+ */
507
+ function cmdValidateCapture(args, io) {
508
+ const { values, positionals } = parseArgs({
509
+ args,
510
+ allowPositionals: true,
511
+ options: {
512
+ openapi: { type: "string" },
513
+ graphql: { type: "string" },
514
+ "graphql-endpoint": { type: "string" },
515
+ origin: {
516
+ type: "string",
517
+ multiple: true
518
+ },
519
+ json: { type: "boolean" }
520
+ }
521
+ });
522
+ const [harPath] = positionals;
523
+ if (!harPath || !values.openapi && !values.graphql) {
524
+ io.err("api validate-capture needs <har.zip> and --openapi <spec.json> and/or --graphql <schema.graphql>\n");
525
+ return 1;
526
+ }
527
+ const verdict = validateCapturedTraffic(readFileSync(harPath), {
528
+ ...values.openapi ? { openapi: JSON.parse(readFileSync(values.openapi, "utf8")) } : {},
529
+ ...values.graphql ? { graphql: {
530
+ endpointPath: values["graphql-endpoint"] ?? "/graphql",
531
+ sdl: readFileSync(values.graphql, "utf8")
532
+ } } : {}
533
+ }, { allowedOrigins: values.origin });
534
+ if (values.json) {
535
+ io.out(`${JSON.stringify(verdict, null, 2)}\n`);
536
+ return verdict.clean ? 0 : 1;
537
+ }
538
+ io.out(`capture: ${verdict.clean ? "clean" : "NOT CLEAN"} (${verdict.entriesValidated} entries)\n`);
539
+ for (const [kind, count] of Object.entries(verdict.findingsByKind)) io.out(` ${count}Ɨ ${kind}\n`);
540
+ if (verdict.firstFailing) {
541
+ const f = verdict.firstFailing;
542
+ io.out(` first failing: ${f.method} ${f.path} — ${f.kind}: ${f.message}\n`);
543
+ }
544
+ io.out(` exercised: ${verdict.exercisedOperations.join(", ") || "(none)"}\n`);
545
+ if (verdict.unexercisedOperations.length > 0) io.out(` unexercised: ${verdict.unexercisedOperations.join(", ")}\n`);
546
+ return verdict.clean ? 0 : 1;
547
+ }
548
+ //#endregion
549
+ //#region src/browser.ts
550
+ /** Flags shared by every browser command. */
551
+ const COMMON_OPTIONS = {
552
+ "allow-host": {
553
+ type: "string",
554
+ multiple: true
555
+ },
556
+ "allow-private": { type: "boolean" },
557
+ "no-sandbox": { type: "boolean" },
558
+ headed: { type: "boolean" },
559
+ engine: { type: "string" },
560
+ json: { type: "boolean" }
561
+ };
562
+ function flagsFrom(values) {
563
+ return {
564
+ allowHost: values["allow-host"] ?? [],
565
+ allowPrivate: values["allow-private"] ?? false,
566
+ noSandbox: values["no-sandbox"] ?? false,
567
+ headed: values.headed ?? false,
568
+ engine: resolveEngine(values.engine)
569
+ };
570
+ }
571
+ /** Launch thunk for a `BrowserManager`, honoring the selected engine + the
572
+ * mandatory SSRF proxy (chromium gets the hardening args; firefox/webkit get the
573
+ * proxy + the Tier-1 route allowlist). */
574
+ function launchFor(flags, proxyUrl) {
575
+ return engineLauncher(flags.engine, {
576
+ headless: !flags.headed,
577
+ proxyServer: proxyUrl,
578
+ noSandbox: flags.noSandbox
579
+ });
580
+ }
581
+ /**
582
+ * Stand up a gated, proxy-fronted browser, navigate to `url`, run `fn`, and tear
583
+ * everything down. Returns `undefined` for a bad URL (after reporting it).
584
+ */
585
+ async function withSession(url, flags, io, fn) {
586
+ let host;
587
+ try {
588
+ host = new URL(url).hostname;
589
+ } catch {
590
+ io.err(`invalid url: ${url}\n`);
591
+ return 1;
592
+ }
593
+ const gate = new BrowserGate({ allowedHosts: [host, ...flags.allowHost] });
594
+ const proxy = await createSsrfProxy({ allowPrivate: flags.allowPrivate });
595
+ const store = new ArtifactStore$1(mkdtempSync(join(tmpdir(), "sackville-browser-cli-")));
596
+ const manager = new BrowserManager({
597
+ gate,
598
+ launch: launchFor(flags, proxy.url)
599
+ });
600
+ try {
601
+ const page = await (await manager.createSession("cli")).newPage();
602
+ const driver = new PageDriver(page, {
603
+ runId: "cli",
604
+ store,
605
+ gate
606
+ });
607
+ await driver.navigate(url);
608
+ return await fn({
609
+ driver,
610
+ store,
611
+ page
612
+ });
613
+ } catch (err) {
614
+ io.err(`${err.message}\n`);
615
+ return 1;
616
+ } finally {
617
+ await manager.shutdown();
618
+ await proxy.close();
619
+ }
620
+ }
621
+ async function runBrowser(args, io) {
622
+ const [sub, ...rest] = args;
623
+ try {
624
+ switch (sub) {
625
+ case "snapshot": return await cmdSnapshot(rest, io);
626
+ case "audit": return await cmdAudit$1(rest, io);
627
+ case "screenshot": return await cmdScreenshot(rest, io);
628
+ case "run": return await cmdRun$2(rest, io);
629
+ default:
630
+ io.err(`unknown browser subcommand: ${sub ?? "(none)"}\n`);
631
+ return 1;
632
+ }
633
+ } catch (err) {
634
+ io.err(`${err.message}\n`);
635
+ return 1;
636
+ }
637
+ }
638
+ /** Parse repeatable `--var k=v` flags (split on the FIRST `=`) into a record. */
639
+ function parseVars$1(raw) {
640
+ const vars = {};
641
+ for (const item of raw ?? []) {
642
+ const eq = item.indexOf("=");
643
+ if (eq === -1) vars[item] = "";
644
+ else vars[item.slice(0, eq)] = item.slice(eq + 1);
645
+ }
646
+ return vars;
647
+ }
648
+ /**
649
+ * Replay a persisted browser flow (`<flow>.bru` + sidecar) — `sackville browser
650
+ * run <flow.bru>`. Unlike the single-shot commands the flow drives its own
651
+ * navigations, so no URL is auto-allowed: the human allowlists target hosts with
652
+ * `--allow-host` and unlocks mutations with `--unsafe` (else they dry-run).
653
+ * `{{secret:NAME}}` resolves from `SACKVILLE_BROWSER_SECRET_<NAME>` env (the human
654
+ * is the operator); a `Redactor` scrubs those values from every result. Exits
655
+ * non-zero when the flow fails (a step error or a failed assertion) — CI-usable.
656
+ */
657
+ async function cmdRun$2(args, io) {
658
+ const { values, positionals } = parseArgs({
659
+ args,
660
+ allowPositionals: true,
661
+ options: {
662
+ ...COMMON_OPTIONS,
663
+ unsafe: { type: "boolean" },
664
+ var: {
665
+ type: "string",
666
+ multiple: true
667
+ }
668
+ }
669
+ });
670
+ const flowPath = positionals[0];
671
+ if (!flowPath) {
672
+ io.err("browser run needs <flow.bru>\n");
673
+ return 1;
674
+ }
675
+ let flow;
676
+ try {
677
+ flow = loadFlow(flowPath);
678
+ } catch (err) {
679
+ io.err(`${err.message}\n`);
680
+ return 1;
681
+ }
682
+ const env = io.env ?? {};
683
+ const redactor = new Redactor$1();
684
+ const secrets = /* @__PURE__ */ new Map();
685
+ for (const [key, val] of Object.entries(env)) {
686
+ const m = /^SACKVILLE_BROWSER_SECRET_(.+)$/.exec(key);
687
+ if (m?.[1] && val) {
688
+ redactor.register(m[1], val);
689
+ secrets.set(m[1], val);
690
+ }
691
+ }
692
+ const flags = flagsFrom(values);
693
+ const gate = new BrowserGate({
694
+ allowUnsafe: values.unsafe ?? false,
695
+ allowedHosts: flags.allowHost
696
+ });
697
+ const proxy = await createSsrfProxy({ allowPrivate: flags.allowPrivate });
698
+ const store = new ArtifactStore$1(mkdtempSync(join(tmpdir(), "sackville-browser-flow-")));
699
+ const manager = new BrowserManager({
700
+ gate,
701
+ launch: launchFor(flags, proxy.url)
702
+ });
703
+ try {
704
+ const result = await runFlow(new PageDriver(await (await manager.createSession("cli")).newPage(), {
705
+ runId: "cli",
706
+ store,
707
+ gate,
708
+ redact: (v) => redactor.redact(v)
709
+ }), flow, {
710
+ vars: parseVars$1(values.var),
711
+ resolveSecret: (name) => secrets.get(name)
712
+ });
713
+ if (values.json) io.out(`${JSON.stringify(result, null, 2)}\n`);
714
+ else printFlowResult(result, io);
715
+ return result.passed ? 0 : 1;
716
+ } catch (err) {
717
+ io.err(`${err.message}\n`);
718
+ return 1;
719
+ } finally {
720
+ await manager.shutdown();
721
+ await proxy.close();
722
+ }
723
+ }
724
+ function printFlowResult(result, io) {
725
+ io.out(`flow: ${result.name}\n`);
726
+ for (const step of result.steps) if (step.error) io.out(` FAIL ${step.action} — ${step.error}\n`);
727
+ else if (step.assertions) {
728
+ const passed = step.assertions.filter((a) => a.pass).length;
729
+ const total = step.assertions.length;
730
+ io.out(` ${passed === total ? "ok " : "FAIL"} assert (${passed}/${total} passed)\n`);
731
+ } else io.out(` ok ${step.action}${step.dryRun ? " (dry-run)" : ""}\n`);
732
+ io.out(`${result.passed ? "PASS" : "FAIL"}\n`);
733
+ }
734
+ async function cmdSnapshot(args, io) {
735
+ const { values, positionals } = parseArgs({
736
+ args,
737
+ allowPositionals: true,
738
+ options: COMMON_OPTIONS
739
+ });
740
+ const url = positionals[0];
741
+ if (!url) {
742
+ io.err("browser snapshot needs <url>\n");
743
+ return 1;
744
+ }
745
+ return withSession(url, flagsFrom(values), io, async ({ driver }) => {
746
+ const snap = await driver.snapshot();
747
+ if (values.json) {
748
+ io.out(`${JSON.stringify(snap, null, 2)}\n`);
749
+ return 0;
750
+ }
751
+ io.out(`${snap.snapshot}\n`);
752
+ if (snap.truncated) io.err("(snapshot truncated — re-run with --json for the full handle)\n");
753
+ return 0;
754
+ });
755
+ }
756
+ async function cmdAudit$1(args, io) {
757
+ const { values, positionals } = parseArgs({
758
+ args,
759
+ allowPositionals: true,
760
+ options: COMMON_OPTIONS
761
+ });
762
+ const url = positionals[0];
763
+ if (!url) {
764
+ io.err("browser audit needs <url>\n");
765
+ return 1;
766
+ }
767
+ return withSession(url, flagsFrom(values), io, async ({ store, page }) => {
768
+ const res = await auditA11y(page, {
769
+ runId: "cli",
770
+ store
771
+ });
772
+ if (values.json) {
773
+ io.out(`${JSON.stringify(res, null, 2)}\n`);
774
+ return res.summary.violationCount === 0 ? 0 : 1;
775
+ }
776
+ const s = res.summary;
777
+ io.out(`violations: ${s.violationCount}\n`);
778
+ for (const v of s.top) io.out(` [${v.impact ?? "-"}] ${v.id}: ${v.nodeCount} node(s) — ${v.help}\n`);
779
+ const path = store.get(res.resultsHandle)?.path;
780
+ if (path) io.out(`full report: ${path}\n`);
781
+ return s.violationCount === 0 ? 0 : 1;
782
+ });
783
+ }
784
+ async function cmdScreenshot(args, io) {
785
+ const { values, positionals } = parseArgs({
786
+ args,
787
+ allowPositionals: true,
788
+ options: {
789
+ ...COMMON_OPTIONS,
790
+ out: { type: "string" },
791
+ "full-page": { type: "boolean" }
792
+ }
793
+ });
794
+ const url = positionals[0];
795
+ if (!url) {
796
+ io.err("browser screenshot needs <url>\n");
797
+ return 1;
798
+ }
799
+ const out = values.out ?? "screenshot.png";
800
+ return withSession(url, flagsFrom(values), io, async ({ driver, store }) => {
801
+ const shot = await driver.screenshot({ fullPage: values["full-page"] ?? false });
802
+ const bytes = shot.handle ? store.get(shot.handle)?.body : void 0;
803
+ if (!bytes) {
804
+ io.err("screenshot capture failed\n");
805
+ return 1;
806
+ }
807
+ writeFileSync(out, bytes);
808
+ if (values.json) {
809
+ io.out(`${JSON.stringify({
810
+ ...shot,
811
+ savedTo: out
812
+ }, null, 2)}\n`);
813
+ return 0;
814
+ }
815
+ io.out(`saved ${shot.byteSize} bytes to ${out}\n`);
816
+ return 0;
817
+ });
818
+ }
819
+ //#endregion
820
+ //#region src/coverage.ts
821
+ /**
822
+ * `sackville coverage` — the human surface over `@sackville-mcp/coverage`.
823
+ *
824
+ * `uncovered-in-diff` is the pure forgotten-assertion catch: classify a diff's new lines
825
+ * against an istanbul or coverage.py report and surface the executable-but-unhit ones.
826
+ * `run-scoped` is the gated impact-scoped runner (`vitest related`). The human IS the
827
+ * operator, so the gate is a straight-through `--allow-run` flag and the typed root is
828
+ * auto-allowed; the test runner is injectable so the suite never spawns a real vitest
829
+ * (ADR 0010: no real spawn in the gate). Both commands exit 1 when a new line is uncovered —
830
+ * the catch is CI-actionable, like `sackville browser audit`.
831
+ */
832
+ async function runCoverage(args, io, deps = {}) {
833
+ const [sub, ...rest] = args;
834
+ switch (sub) {
835
+ case "uncovered-in-diff": return cmdUncoveredInDiff(rest, io);
836
+ case "run-scoped": return cmdRunScoped(rest, io, deps);
837
+ default:
838
+ io.err(`unknown coverage subcommand: ${sub ?? "(none)"}\n`);
839
+ return 1;
840
+ }
841
+ }
842
+ function printReport(io, report) {
843
+ const s = report.summary;
844
+ io.out(`files: ${report.files.length} (${s.filesWithoutCoverage} without coverage) covered ${s.covered} uncovered ${s.uncovered} non-executable ${s.nonExecutable}\n`);
845
+ if (report.uncovered.length === 0) {
846
+ io.out("uncovered new lines: (none)\n");
847
+ return;
848
+ }
849
+ io.out(`uncovered new lines (${report.uncovered.length}):\n`);
850
+ for (const u of report.uncovered) io.out(` ${u.path}:${u.line}\n`);
851
+ }
852
+ function cmdUncoveredInDiff(args, io) {
853
+ const { values } = parseArgs({
854
+ args,
855
+ allowPositionals: true,
856
+ options: {
857
+ diff: { type: "string" },
858
+ coverage: { type: "string" },
859
+ "coverage-format": { type: "string" },
860
+ "project-root": { type: "string" },
861
+ json: { type: "boolean" }
862
+ }
863
+ });
864
+ if (!values.diff || !values.coverage) {
865
+ io.err("coverage uncovered-in-diff needs --diff <file> and --coverage <file>\n");
866
+ return 1;
867
+ }
868
+ const format = values["coverage-format"] ?? "istanbul";
869
+ if (format !== "istanbul" && format !== "coveragepy") {
870
+ io.err(`unknown coverage format: ${format} (expected istanbul|coveragepy)\n`);
871
+ return 1;
872
+ }
873
+ const diff = readFileSync(values.diff, "utf8");
874
+ const parsed = JSON.parse(readFileSync(values.coverage, "utf8"));
875
+ const report = uncoveredInDiff(diff, format === "coveragepy" ? coveragePyToIstanbul(parsed) : parsed, { projectRoot: values["project-root"] });
876
+ if (values.json) io.out(`${JSON.stringify(report, null, 2)}\n`);
877
+ else printReport(io, report);
878
+ return report.uncovered.length === 0 ? 0 : 1;
879
+ }
880
+ async function cmdRunScoped(args, io, deps) {
881
+ const { values, positionals } = parseArgs({
882
+ args,
883
+ allowPositionals: true,
884
+ options: {
885
+ "changed-file": {
886
+ type: "string",
887
+ multiple: true
888
+ },
889
+ diff: { type: "string" },
890
+ python: { type: "boolean" },
891
+ measure: {
892
+ type: "string",
893
+ multiple: true
894
+ },
895
+ "scope-mode": { type: "string" },
896
+ "allow-run": { type: "boolean" },
897
+ "timeout-ms": { type: "string" },
898
+ json: { type: "boolean" }
899
+ }
900
+ });
901
+ const projectRoot = positionals[0];
902
+ if (!projectRoot) {
903
+ io.err("coverage run-scoped needs a <project-root>\n");
904
+ return 1;
905
+ }
906
+ const scopeMode = values["scope-mode"] ?? "report-gap";
907
+ if (values.python && scopeMode !== "report-gap" && scopeMode !== "widen") {
908
+ io.err(`unknown scope mode: ${scopeMode} (expected report-gap|widen)\n`);
909
+ return 1;
910
+ }
911
+ const timeoutRaw = values["timeout-ms"];
912
+ const timeoutMs = timeoutRaw !== void 0 ? Number(timeoutRaw) : void 0;
913
+ try {
914
+ const config = {
915
+ projectRoot,
916
+ allowedRoots: [resolve(projectRoot)],
917
+ allowRun: values["allow-run"] ?? false,
918
+ timeoutMs: timeoutMs !== void 0 && Number.isFinite(timeoutMs) ? timeoutMs : void 0
919
+ };
920
+ const changedFiles = values["changed-file"] ?? [];
921
+ const diff = values.diff !== void 0 ? readFileSync(values.diff, "utf8") : void 0;
922
+ const result = values.python ? await runScopedPython(config, {
923
+ changedFiles,
924
+ diff,
925
+ measureTargets: values.measure ?? [],
926
+ scopeMode
927
+ }, { runner: deps.runner }) : await runScoped(config, {
928
+ changedFiles,
929
+ diff
930
+ }, { runner: deps.runner });
931
+ const py = values.python ? result : void 0;
932
+ if (values.json) io.out(`${JSON.stringify(result, null, 2)}\n`);
933
+ else if (!result.ran) io.out("no changed files — nothing to run\n");
934
+ else {
935
+ io.out(`ran ${values.python ? "pytest" : "vitest"} (exit ${result.exitCode}); tests ${py?.inconclusive ? "INCONCLUSIVE" : result.passed ? "passed" : "FAILED"}; scoped: ${result.scopedFiles.join(", ")}\n`);
936
+ if (py?.unmatched) io.out(`uncovered-by-scope (no mirrored test): ${py.unmatched.join(", ")}\n`);
937
+ if (result.report) printReport(io, result.report);
938
+ }
939
+ return result.passed && !py?.inconclusive && (result.report ? result.report.uncovered.length === 0 : true) ? 0 : 1;
940
+ } catch (e) {
941
+ if (e instanceof CoverageGateError) {
942
+ io.err(`refused: ${e.message} (pass --allow-run)\n`);
943
+ return 1;
944
+ }
945
+ io.err(`${e.message}\n`);
946
+ return 1;
947
+ }
948
+ }
949
+ //#endregion
950
+ //#region src/deps.ts
951
+ /** Map an OSV ecosystem to the `@sackville-mcp/core` installed-version detection ecosystem. */
952
+ const DETECT_ECOSYSTEM = {
953
+ npm: "node",
954
+ PyPI: "python",
955
+ RubyGems: "ruby"
956
+ };
957
+ /**
958
+ * `sackville deps` — the human surface over `@sackville-mcp/deps`. Answers deprecation /
959
+ * vulnerability / freshness for the version ACTUALLY INSTALLED in a project (not "latest").
960
+ *
961
+ * The human invoked the audit, so the CLI fetches by default (operator intent), with the
962
+ * same SSRF pre-flight the bins use (`resolveAndPin`: metadata/link-local always refused,
963
+ * private registries gated by `--allow-private`). Network + comparator dispatch reuse the
964
+ * ecosystem helpers lifted into `@sackville-mcp/deps`. `audit`/`audit-project` exit 1 on a
965
+ * security or deprecation finding (CI-actionable); `changelog` is informational.
966
+ */
967
+ async function runDeps(args, io, deps = {}) {
968
+ const [sub, ...rest] = args;
969
+ switch (sub) {
970
+ case "audit": return cmdAudit(rest, io, deps);
971
+ case "audit-project": return cmdAuditProject(rest, io, deps);
972
+ case "changelog": return cmdChangelog(rest, io, deps);
973
+ default:
974
+ io.err(`unknown deps subcommand: ${sub ?? "(none)"}\n`);
975
+ return 1;
976
+ }
977
+ }
978
+ /** Operator registry/SSRF flags shared by every deps command. */
979
+ const REGISTRY_OPTIONS = {
980
+ ecosystem: { type: "string" },
981
+ "osv-db": { type: "string" },
982
+ registry: { type: "string" },
983
+ "pypi-registry": { type: "string" },
984
+ "rubygems-registry": { type: "string" },
985
+ "allow-private": { type: "boolean" },
986
+ json: { type: "boolean" }
987
+ };
988
+ function registriesFrom(values) {
989
+ return {
990
+ registry: values.registry || "https://registry.npmjs.org",
991
+ pypiRegistry: values["pypi-registry"] || "https://pypi.org/pypi",
992
+ rubygemsRegistry: values["rubygems-registry"] || "https://rubygems.org/api/v1",
993
+ allowPrivate: values["allow-private"] ?? false
994
+ };
995
+ }
996
+ function ecosystemFrom(values, io) {
997
+ const e = values.ecosystem ?? "npm";
998
+ if (e !== "npm" && e !== "PyPI" && e !== "RubyGems") {
999
+ io.err(`unknown ecosystem: ${e} (expected npm|PyPI|RubyGems)\n`);
1000
+ return null;
1001
+ }
1002
+ return e;
1003
+ }
1004
+ /** npm packument path: keep a scope's `@` but escape the `/` (registry idiom). */
1005
+ function packumentUrl(registry, packageName) {
1006
+ return `${registry.replace(/\/+$/, "")}/${packageName.replace("/", "%2f")}`;
1007
+ }
1008
+ /** Build the SSRF-pinned packument fetcher (npm/PyPI/RubyGems), mirroring the deps bin. */
1009
+ function makeFetcher(r) {
1010
+ return async (packageName, ecosystem) => {
1011
+ if (ecosystem === "npm") {
1012
+ const url = packumentUrl(r.registry, packageName);
1013
+ await resolveAndPin(new URL(url).hostname, void 0, { allowPrivate: r.allowPrivate });
1014
+ const res = await fetch(url, { headers: { accept: "application/json" } });
1015
+ if (!res.ok) throw new Error(`registry returned ${res.status} for ${packageName}`);
1016
+ return await res.json();
1017
+ }
1018
+ if (ecosystem === "PyPI") {
1019
+ const url = `${r.pypiRegistry.replace(/\/+$/, "")}/${encodeURIComponent(normalizePypiName(packageName))}/json`;
1020
+ await resolveAndPin(new URL(url).hostname, void 0, { allowPrivate: r.allowPrivate });
1021
+ const res = await fetch(url, { headers: { accept: "application/json" } });
1022
+ if (!res.ok) throw new Error(`PyPI returned ${res.status} for ${packageName}`);
1023
+ return pypiJsonToPackument(await res.json());
1024
+ }
1025
+ const url = `${r.rubygemsRegistry.replace(/\/+$/, "")}/versions/${encodeURIComponent(packageName)}.json`;
1026
+ await resolveAndPin(new URL(url).hostname, void 0, { allowPrivate: r.allowPrivate });
1027
+ const res = await fetch(url, { headers: { accept: "application/json" } });
1028
+ if (!res.ok) throw new Error(`RubyGems returned ${res.status} for ${packageName}`);
1029
+ return rubygemsToPackument(packageName, await res.json());
1030
+ };
1031
+ }
1032
+ /** SSRF-pinned JSON GET (pre-flight resolve-and-refuse, then fetch). */
1033
+ async function pinnedFetchJson(url, allowPrivate) {
1034
+ await resolveAndPin(new URL(url).hostname, void 0, { allowPrivate });
1035
+ const res = await fetch(url, { headers: { accept: "application/json" } });
1036
+ if (!res.ok) throw new Error(`metadata fetch returned ${res.status} for ${url}`);
1037
+ return res.json();
1038
+ }
1039
+ /**
1040
+ * Build the SSRF-pinned changelog fetcher (npm/PyPI/RubyGems). Resolves the source GitHub repo
1041
+ * from registry metadata — npm packument `repository`, PyPI `info.project_urls`, or the RubyGems
1042
+ * `/api/v1/gems/<name>.json` `source_code_uri`/`homepage_uri` — then fetches the CHANGELOG from
1043
+ * `raw.githubusercontent.com/<owner>/<repo>/HEAD/<file>`, pinning every request.
1044
+ */
1045
+ function makeChangelogFetcher(r) {
1046
+ const fetchPackument = makeFetcher(r);
1047
+ const repoUrlFor = async (packageName, ecosystem) => {
1048
+ if (ecosystem === "npm") return npmRepoUrl(await fetchPackument(packageName, "npm"));
1049
+ if (ecosystem === "PyPI") return pypiRepoUrl(await pinnedFetchJson(`${r.pypiRegistry.replace(/\/+$/, "")}/${encodeURIComponent(normalizePypiName(packageName))}/json`, r.allowPrivate));
1050
+ if (ecosystem === "RubyGems") return gemRepoUrl(await pinnedFetchJson(`${r.rubygemsRegistry.replace(/\/+$/, "")}/gems/${encodeURIComponent(packageName)}.json`, r.allowPrivate));
1051
+ throw new Error(`changelog fetch supports npm, PyPI, and RubyGems (got "${ecosystem}")`);
1052
+ };
1053
+ return async (packageName, ecosystem) => {
1054
+ const gh = githubOwnerRepo(await repoUrlFor(packageName, ecosystem));
1055
+ if (!gh) throw new Error(`could not resolve a GitHub repository for "${packageName}"`);
1056
+ for (const file of CHANGELOG_FILENAMES) {
1057
+ const url = `https://raw.githubusercontent.com/${gh.owner}/${gh.repo}/HEAD/${file}`;
1058
+ await resolveAndPin(new URL(url).hostname, void 0, { allowPrivate: r.allowPrivate });
1059
+ const res = await fetch(url);
1060
+ if (res.ok) return {
1061
+ text: await res.text(),
1062
+ source: url
1063
+ };
1064
+ }
1065
+ throw new Error(`no CHANGELOG found in github.com/${gh.owner}/${gh.repo}`);
1066
+ };
1067
+ }
1068
+ /** Load advisories + snapshotDate for an ecosystem, or empty when no snapshot dir is set. */
1069
+ function loadAdvisories(osvDir, ecosystem) {
1070
+ if (osvDir === void 0) return {
1071
+ advisories: [],
1072
+ loaded: false
1073
+ };
1074
+ const snapshot = loadOsvSnapshot(osvDir, ecosystem);
1075
+ return {
1076
+ advisories: snapshot.advisories,
1077
+ snapshotDate: snapshot.snapshotDate,
1078
+ loaded: true
1079
+ };
1080
+ }
1081
+ /** Detect → fetch → audit one package (the thin orchestration the pure core needs). */
1082
+ async function auditOne(project, packageName, ecosystem, fetchPackument, advisories, snapshotDate, versionOverride) {
1083
+ const version = versionOverride ?? detectInstalledVersion(project, packageName, { ecosystem: DETECT_ECOSYSTEM[ecosystem] }).version;
1084
+ if (version === null || version === void 0) throw new Error(`could not detect an installed version of "${packageName}" in ${project}`);
1085
+ const packument = await fetchPackument(packageName, ecosystem);
1086
+ return auditDependency({
1087
+ packageName: matchName(packageName, ecosystem),
1088
+ ecosystem,
1089
+ installedVersion: version,
1090
+ packument,
1091
+ advisories,
1092
+ snapshotDate,
1093
+ comparator: comparatorFor(ecosystem)
1094
+ });
1095
+ }
1096
+ /**
1097
+ * Detect → fetch → audit each declared (or `names`-scoped) dependency of a project,
1098
+ * isolating per-package failures — the reusable project audit the `verify run --deps`
1099
+ * path reuses (mirrors the MCP `auditProjectDependencies`). Returns the
1100
+ * `{audits, osvSnapshotLoaded}` shape the verify orchestrator's deps adapter consumes.
1101
+ */
1102
+ async function auditProjectScoped(input) {
1103
+ const { advisories, snapshotDate, loaded } = loadAdvisories(input.osvDir, input.ecosystem);
1104
+ const names = input.names ?? dependencyNames(input.project, input.ecosystem, true);
1105
+ const audits = [];
1106
+ const errors = [];
1107
+ for (const name of names) try {
1108
+ audits.push(await auditOne(input.project, name, input.ecosystem, input.fetchPackument, advisories, snapshotDate));
1109
+ } catch (e) {
1110
+ errors.push({
1111
+ package: name,
1112
+ error: e.message
1113
+ });
1114
+ }
1115
+ return {
1116
+ audits,
1117
+ osvSnapshotLoaded: loaded,
1118
+ errors
1119
+ };
1120
+ }
1121
+ /** A security or deprecation finding — the CI-actionable signal (outdated alone is not). */
1122
+ function isActionable(audit) {
1123
+ return audit.worstSeverity !== "none" || audit.deprecated.isDeprecated;
1124
+ }
1125
+ function printAudit(io, audit, loaded, snapshotDate) {
1126
+ io.out(`${audit.package} ${audit.installedVersion} (${audit.ecosystem})\n`);
1127
+ io.out(audit.deprecated.isDeprecated ? `deprecated [${audit.deprecated.scope}]: ${audit.deprecated.message}\n` : "deprecated: no\n");
1128
+ if (audit.vulnerabilities.length > 0) {
1129
+ io.out(`vulnerabilities (${audit.vulnerabilities.length}):\n`);
1130
+ for (const v of audit.vulnerabilities) {
1131
+ const fixed = v.fixedIn.length ? ` fixed in: ${v.fixedIn.join(", ")}` : "";
1132
+ io.out(` ${v.id} [${v.severity}] ${v.summary ?? ""}${fixed}\n`);
1133
+ }
1134
+ } else io.out("vulnerabilities: none\n");
1135
+ const f = audit.freshness;
1136
+ io.out(`freshness: installed ${f.installed}, latest ${f.latest ?? "?"}, same-major ${f.latestSameMajor ?? "?"}, outdated ${f.isOutdated ? "yes" : "no"}\n`);
1137
+ if (audit.recommendedTarget) io.out(`recommended target: ${audit.recommendedTarget}\n`);
1138
+ if (audit.minimumSafeUpgrade) io.out(`minimum safe upgrade: ${audit.minimumSafeUpgrade}\n`);
1139
+ io.out(loaded ? `osv snapshot: loaded${snapshotDate ? ` (${snapshotDate})` : ""}\n` : "osv snapshot: NOT loaded — treat \"no known vulnerabilities\" as unknown, not clean\n");
1140
+ }
1141
+ async function cmdAudit(args, io, deps) {
1142
+ const { values, positionals } = parseArgs({
1143
+ args,
1144
+ allowPositionals: true,
1145
+ options: {
1146
+ ...REGISTRY_OPTIONS,
1147
+ version: { type: "string" }
1148
+ }
1149
+ });
1150
+ const [project, packageName] = positionals;
1151
+ if (!project || !packageName) {
1152
+ io.err("deps audit needs <project> <package>\n");
1153
+ return 1;
1154
+ }
1155
+ const ecosystem = ecosystemFrom(values, io);
1156
+ if (!ecosystem) return 1;
1157
+ const r = registriesFrom(values);
1158
+ const fetchPackument = deps.fetchPackument ?? makeFetcher(r);
1159
+ const { advisories, snapshotDate, loaded } = loadAdvisories(values["osv-db"], ecosystem);
1160
+ try {
1161
+ const audit = await auditOne(project, packageName, ecosystem, fetchPackument, advisories, snapshotDate, values.version);
1162
+ if (values.json) io.out(`${JSON.stringify({
1163
+ ...audit,
1164
+ osvSnapshotLoaded: loaded
1165
+ }, null, 2)}\n`);
1166
+ else printAudit(io, audit, loaded, snapshotDate);
1167
+ return isActionable(audit) ? 1 : 0;
1168
+ } catch (e) {
1169
+ io.err(`${e.message}\n`);
1170
+ return 1;
1171
+ }
1172
+ }
1173
+ async function cmdAuditProject(args, io, deps) {
1174
+ const { values, positionals } = parseArgs({
1175
+ args,
1176
+ allowPositionals: true,
1177
+ options: {
1178
+ ...REGISTRY_OPTIONS,
1179
+ "skip-dev": { type: "boolean" }
1180
+ }
1181
+ });
1182
+ const project = positionals[0];
1183
+ if (!project) {
1184
+ io.err("deps audit-project needs <project>\n");
1185
+ return 1;
1186
+ }
1187
+ const ecosystem = ecosystemFrom(values, io);
1188
+ if (!ecosystem) return 1;
1189
+ const r = registriesFrom(values);
1190
+ const fetchPackument = deps.fetchPackument ?? makeFetcher(r);
1191
+ const { advisories, snapshotDate, loaded } = loadAdvisories(values["osv-db"], ecosystem);
1192
+ const names = dependencyNames(project, ecosystem, !values["skip-dev"]);
1193
+ const dependencies = [];
1194
+ const errors = [];
1195
+ for (const name of names) try {
1196
+ const audit = await auditOne(project, name, ecosystem, fetchPackument, advisories, snapshotDate);
1197
+ dependencies.push({
1198
+ package: name,
1199
+ installedVersion: audit.installedVersion,
1200
+ worstSeverity: audit.worstSeverity,
1201
+ deprecated: audit.deprecated.isDeprecated,
1202
+ isOutdated: audit.freshness.isOutdated,
1203
+ recommendedTarget: audit.recommendedTarget,
1204
+ minimumSafeUpgrade: audit.minimumSafeUpgrade,
1205
+ vulnerabilityCount: audit.vulnerabilities.length
1206
+ });
1207
+ } catch (err) {
1208
+ errors.push({
1209
+ package: name,
1210
+ error: err instanceof Error ? err.message : String(err)
1211
+ });
1212
+ }
1213
+ const bySeverity = {};
1214
+ for (const d of dependencies) if (d.worstSeverity !== "none") bySeverity[d.worstSeverity] = (bySeverity[d.worstSeverity] ?? 0) + 1;
1215
+ const summary = {
1216
+ total: dependencies.length,
1217
+ withFindings: dependencies.filter((d) => d.worstSeverity !== "none" || d.deprecated).length,
1218
+ deprecated: dependencies.filter((d) => d.deprecated).length,
1219
+ outdated: dependencies.filter((d) => d.isOutdated).length,
1220
+ bySeverity,
1221
+ osvSnapshotLoaded: loaded,
1222
+ snapshotDate
1223
+ };
1224
+ if (values.json) {
1225
+ io.out(`${JSON.stringify({
1226
+ project,
1227
+ ecosystem,
1228
+ summary,
1229
+ dependencies,
1230
+ errors
1231
+ }, null, 2)}\n`);
1232
+ return summary.withFindings > 0 ? 1 : 0;
1233
+ }
1234
+ io.out(`${project} (${ecosystem}) ${summary.total} deps; findings ${summary.withFindings}, deprecated ${summary.deprecated}, outdated ${summary.outdated}\n`);
1235
+ io.out(loaded ? `osv snapshot: loaded${snapshotDate ? ` (${snapshotDate})` : ""}\n` : "osv snapshot: NOT loaded — \"no known vulnerabilities\" is unknown, not clean\n");
1236
+ for (const d of dependencies) {
1237
+ if (d.worstSeverity === "none" && !d.deprecated && !d.isOutdated) continue;
1238
+ const tags = [
1239
+ d.worstSeverity !== "none" ? `[${d.worstSeverity}]` : "",
1240
+ d.deprecated ? "deprecated" : "",
1241
+ d.isOutdated ? "outdated" : ""
1242
+ ].filter(Boolean).join(" ");
1243
+ const target = d.minimumSafeUpgrade ?? d.recommendedTarget;
1244
+ io.out(` ${d.package} ${d.installedVersion} ${tags}${target ? ` → ${target}` : ""}\n`);
1245
+ }
1246
+ for (const e of errors) io.out(` ! ${e.package}: ${e.error}\n`);
1247
+ return summary.withFindings > 0 ? 1 : 0;
1248
+ }
1249
+ async function cmdChangelog(args, io, deps) {
1250
+ const { values, positionals } = parseArgs({
1251
+ args,
1252
+ allowPositionals: true,
1253
+ options: {
1254
+ ...REGISTRY_OPTIONS,
1255
+ project: { type: "string" },
1256
+ from: { type: "string" },
1257
+ to: { type: "string" }
1258
+ }
1259
+ });
1260
+ const packageName = positionals[0];
1261
+ if (!packageName) {
1262
+ io.err("deps changelog needs <package> (with --from or --project to detect the installed version)\n");
1263
+ return 1;
1264
+ }
1265
+ const ecosystem = ecosystemFrom(values, io);
1266
+ if (!ecosystem) return 1;
1267
+ const r = registriesFrom(values);
1268
+ const fetchChangelog = deps.fetchChangelog ?? makeChangelogFetcher(r);
1269
+ try {
1270
+ let from = values.from;
1271
+ if (from === void 0) {
1272
+ if (!values.project) {
1273
+ io.err("provide --from <version> or --project <dir> to determine the installed version\n");
1274
+ return 1;
1275
+ }
1276
+ const detected = detectInstalledVersion(values.project, packageName, { ecosystem: DETECT_ECOSYSTEM[ecosystem] });
1277
+ if (!detected.version) {
1278
+ io.err(`could not detect an installed version of "${packageName}" in ${values.project}\n`);
1279
+ return 1;
1280
+ }
1281
+ from = detected.version;
1282
+ }
1283
+ const { text: markdown, source } = await fetchChangelog(packageName, ecosystem);
1284
+ const slice = sliceChangelog(markdown, {
1285
+ from,
1286
+ to: values.to,
1287
+ comparator: comparatorFor(ecosystem)
1288
+ });
1289
+ if (values.json) {
1290
+ io.out(`${JSON.stringify({
1291
+ package: packageName,
1292
+ from: slice.from,
1293
+ to: slice.to ?? null,
1294
+ versionsCovered: slice.entries.map((e) => e.version),
1295
+ source,
1296
+ body: slice.entries.map((e) => e.body).join("\n\n")
1297
+ }, null, 2)}\n`);
1298
+ return 0;
1299
+ }
1300
+ io.out(`${packageName} ${slice.from} → ${slice.to ?? "latest"} (${slice.entries.length} section(s)) [${source}]\n`);
1301
+ if (slice.entries.length === 0) {
1302
+ io.out("(no changelog sections in the requested range)\n");
1303
+ return 0;
1304
+ }
1305
+ for (const e of slice.entries) io.out(`\n## ${e.version}\n${e.body}\n`);
1306
+ return 0;
1307
+ } catch (e) {
1308
+ io.err(`${e.message}\n`);
1309
+ return 1;
1310
+ }
1311
+ }
1312
+ //#endregion
1313
+ //#region src/flake.ts
1314
+ /**
1315
+ * `sackville flake` — the human surface over `@sackville-mcp/flake`.
1316
+ *
1317
+ * Reads (`status`/`candidates`) + `ingest`/`release` are always available against the
1318
+ * operator's private run-history DB (`--db`). `run` (spawns vitest) and `quarantine`
1319
+ * (the only write) each sit behind their own paired deny-by-default gate. The human IS
1320
+ * the operator, so those gates are straight-through flags (`--allow-run` /
1321
+ * `--allow-quarantine` + `--max-expiry-ms`), the typed root is auto-allowed, and the
1322
+ * vitest runner is injectable so the suite never spawns a real vitest (ADR 0010).
1323
+ */
1324
+ async function runFlake(args, io, deps = {}) {
1325
+ const [sub, ...rest] = args;
1326
+ switch (sub) {
1327
+ case "status": return withStore(rest, io, cmdStatus);
1328
+ case "candidates": return withStore(rest, io, cmdCandidates);
1329
+ case "ingest": return withStore(rest, io, cmdIngest);
1330
+ case "release": return withStore(rest, io, cmdRelease);
1331
+ case "run": return withStore(rest, io, (store, a, o) => cmdRun$1(store, a, o, deps));
1332
+ case "quarantine": return withStore(rest, io, cmdQuarantine);
1333
+ default:
1334
+ io.err(`unknown flake subcommand: ${sub ?? "(none)"}\n`);
1335
+ return 1;
1336
+ }
1337
+ }
1338
+ /** Open the operator's history DB (from `--db`/`SACKVILLE_FLAKE_DB`), run the command, close. */
1339
+ async function withStore(args, io, fn) {
1340
+ const dbPath = readDbFlag(args) ?? io.env?.SACKVILLE_FLAKE_DB;
1341
+ if (!dbPath) {
1342
+ io.err("no run-history DB given: pass --db <file> or set SACKVILLE_FLAKE_DB\n");
1343
+ return 1;
1344
+ }
1345
+ const store = HistoryStore.open(dbPath);
1346
+ try {
1347
+ return await fn(store, args, io);
1348
+ } finally {
1349
+ store.close();
1350
+ }
1351
+ }
1352
+ /** Pull just `--db <path>` out of an argv without consuming the rest. */
1353
+ function readDbFlag(args) {
1354
+ const i = args.indexOf("--db");
1355
+ return i >= 0 ? args[i + 1] : void 0;
1356
+ }
1357
+ function num$2(raw) {
1358
+ if (raw === void 0) return void 0;
1359
+ const n = Number(raw);
1360
+ return Number.isFinite(n) ? n : void 0;
1361
+ }
1362
+ function printVerdict$1(io, v) {
1363
+ io.out(` [${v.state}] ${v.id} runs ${v.runs} fail ${v.failures}/${v.runs} score ${v.flakeScore.toFixed(3)}\n`);
1364
+ }
1365
+ function cmdStatus(store, args, io) {
1366
+ const { values } = parseArgs({
1367
+ args,
1368
+ allowPositionals: true,
1369
+ options: {
1370
+ db: { type: "string" },
1371
+ "min-runs": { type: "string" },
1372
+ "limit-per-test": { type: "string" },
1373
+ since: { type: "string" },
1374
+ json: { type: "boolean" }
1375
+ }
1376
+ });
1377
+ const verdicts = store.classify({
1378
+ minRuns: num$2(values["min-runs"]),
1379
+ limitPerTest: num$2(values["limit-per-test"]),
1380
+ since: values.since
1381
+ });
1382
+ const quarantined = new Quarantine(store, {
1383
+ allowQuarantine: false,
1384
+ maxExpiryMs: 0
1385
+ }).active();
1386
+ if (values.json) {
1387
+ const summary = {};
1388
+ for (const v of verdicts) summary[v.state] = (summary[v.state] ?? 0) + 1;
1389
+ io.out(`${JSON.stringify({
1390
+ summary,
1391
+ verdicts,
1392
+ quarantined
1393
+ }, null, 2)}\n`);
1394
+ return 0;
1395
+ }
1396
+ const counts = {
1397
+ flaky: 0,
1398
+ reliable: 0,
1399
+ broken: 0,
1400
+ "insufficient-data": 0
1401
+ };
1402
+ for (const v of verdicts) counts[v.state]++;
1403
+ io.out(`flaky ${counts.flaky} reliable ${counts.reliable} broken ${counts.broken} insufficient-data ${counts["insufficient-data"]}\n`);
1404
+ if (verdicts.length > 0) {
1405
+ io.out("verdicts:\n");
1406
+ for (const v of verdicts) printVerdict$1(io, v);
1407
+ }
1408
+ if (quarantined.length > 0) {
1409
+ io.out(`quarantined (${quarantined.length}):\n`);
1410
+ for (const q of quarantined) io.out(` ${q.testId} until ${q.expiresAt} (${q.reason})\n`);
1411
+ }
1412
+ return 0;
1413
+ }
1414
+ function cmdCandidates(store, args, io) {
1415
+ const { values } = parseArgs({
1416
+ args,
1417
+ allowPositionals: true,
1418
+ options: {
1419
+ db: { type: "string" },
1420
+ "min-flake-score": { type: "string" },
1421
+ "min-runs": { type: "string" },
1422
+ json: { type: "boolean" }
1423
+ }
1424
+ });
1425
+ const candidates = quarantineCandidates(store.classify({ minRuns: num$2(values["min-runs"]) }), { minFlakeScore: num$2(values["min-flake-score"]) });
1426
+ if (values.json) {
1427
+ io.out(`${JSON.stringify({ candidates }, null, 2)}\n`);
1428
+ return 0;
1429
+ }
1430
+ if (candidates.length === 0) {
1431
+ io.out("no quarantine candidates\n");
1432
+ return 0;
1433
+ }
1434
+ io.out(`candidates (${candidates.length}):\n`);
1435
+ for (const v of candidates) printVerdict$1(io, v);
1436
+ return 0;
1437
+ }
1438
+ function cmdIngest(store, args, io) {
1439
+ const { values, positionals } = parseArgs({
1440
+ args,
1441
+ allowPositionals: true,
1442
+ options: {
1443
+ db: { type: "string" },
1444
+ format: { type: "string" },
1445
+ at: { type: "string" },
1446
+ "project-root": { type: "string" },
1447
+ "run-group": { type: "string" },
1448
+ json: { type: "boolean" }
1449
+ }
1450
+ });
1451
+ const reportFile = positionals[0];
1452
+ if (!reportFile) {
1453
+ io.err("flake ingest needs a <report-file>\n");
1454
+ return 1;
1455
+ }
1456
+ const format = values.format ?? "vitest";
1457
+ if (format !== "vitest" && format !== "pytest") {
1458
+ io.err(`unknown report format: ${format} (expected vitest|pytest)\n`);
1459
+ return 1;
1460
+ }
1461
+ const report = JSON.parse(readFileSync(reportFile, "utf8"));
1462
+ const opts = {
1463
+ at: values.at ?? (/* @__PURE__ */ new Date()).toISOString(),
1464
+ projectRoot: values["project-root"],
1465
+ runGroup: values["run-group"]
1466
+ };
1467
+ const recorded = format === "pytest" ? store.ingestPytestReport(report, opts) : store.ingestReport(report, opts);
1468
+ if (values.json) {
1469
+ io.out(`${JSON.stringify({
1470
+ format,
1471
+ recorded
1472
+ }, null, 2)}\n`);
1473
+ return 0;
1474
+ }
1475
+ io.out(`recorded ${recorded} run(s) from the ${format} report\n`);
1476
+ return 0;
1477
+ }
1478
+ function cmdRelease(store, args, io) {
1479
+ const { positionals } = parseArgs({
1480
+ args,
1481
+ allowPositionals: true,
1482
+ options: { db: { type: "string" } }
1483
+ });
1484
+ const testId = positionals[0];
1485
+ if (!testId) {
1486
+ io.err("flake release needs a <testId>\n");
1487
+ return 1;
1488
+ }
1489
+ const released = new Quarantine(store, {
1490
+ allowQuarantine: false,
1491
+ maxExpiryMs: 0
1492
+ }).release(testId);
1493
+ io.out(released ? `released ${testId}\n` : `${testId} was not quarantined\n`);
1494
+ return 0;
1495
+ }
1496
+ async function cmdRun$1(store, args, io, deps) {
1497
+ const { values, positionals } = parseArgs({
1498
+ args,
1499
+ allowPositionals: true,
1500
+ options: {
1501
+ db: { type: "string" },
1502
+ framework: { type: "string" },
1503
+ repeat: { type: "string" },
1504
+ file: {
1505
+ type: "string",
1506
+ multiple: true
1507
+ },
1508
+ "run-group": { type: "string" },
1509
+ "allow-run": { type: "boolean" },
1510
+ "timeout-ms": { type: "string" },
1511
+ json: { type: "boolean" }
1512
+ }
1513
+ });
1514
+ const projectRoot = positionals[0];
1515
+ if (!projectRoot) {
1516
+ io.err("flake run needs a <project-root>\n");
1517
+ return 1;
1518
+ }
1519
+ const framework = values.framework ?? "vitest";
1520
+ if (framework !== "vitest" && framework !== "pytest") {
1521
+ io.err(`unknown framework: ${framework} (expected vitest|pytest)\n`);
1522
+ return 1;
1523
+ }
1524
+ try {
1525
+ const result = await (framework === "pytest" ? runAndRecordPytest : runAndRecord)(store, {
1526
+ projectRoot,
1527
+ allowedRoots: [resolve(projectRoot)],
1528
+ allowRun: values["allow-run"] ?? false,
1529
+ timeoutMs: num$2(values["timeout-ms"])
1530
+ }, {
1531
+ repeat: num$2(values.repeat) ?? 1,
1532
+ files: values.file,
1533
+ runGroup: values["run-group"]
1534
+ }, { runner: deps.runner });
1535
+ if (values.json) {
1536
+ io.out(`${JSON.stringify(result, null, 2)}\n`);
1537
+ return 0;
1538
+ }
1539
+ io.out(`ran ${result.iterations} iteration(s); recorded ${result.recorded} run(s)\n`);
1540
+ for (const v of result.verdicts) printVerdict$1(io, v);
1541
+ return 0;
1542
+ } catch (e) {
1543
+ if (e instanceof FlakeGateError) {
1544
+ io.err(`refused: ${e.message} (pass --allow-run)\n`);
1545
+ return 1;
1546
+ }
1547
+ io.err(`${e.message}\n`);
1548
+ return 1;
1549
+ }
1550
+ }
1551
+ function cmdQuarantine(store, args, io) {
1552
+ const { values, positionals } = parseArgs({
1553
+ args,
1554
+ allowPositionals: true,
1555
+ options: {
1556
+ db: { type: "string" },
1557
+ reason: { type: "string" },
1558
+ "expires-at": { type: "string" },
1559
+ "flake-score": { type: "string" },
1560
+ "allow-quarantine": { type: "boolean" },
1561
+ "max-expiry-ms": { type: "string" },
1562
+ json: { type: "boolean" }
1563
+ }
1564
+ });
1565
+ const testId = positionals[0];
1566
+ if (!testId || !values.reason || !values["expires-at"]) {
1567
+ io.err("flake quarantine needs <testId> --reason <r> --expires-at <ISO>\n");
1568
+ return 1;
1569
+ }
1570
+ const policy = {
1571
+ allowQuarantine: values["allow-quarantine"] ?? false,
1572
+ maxExpiryMs: num$2(values["max-expiry-ms"]) ?? 0
1573
+ };
1574
+ try {
1575
+ const entry = new Quarantine(store, policy).quarantine({
1576
+ testId,
1577
+ reason: values.reason,
1578
+ expiresAt: values["expires-at"],
1579
+ flakeScore: num$2(values["flake-score"])
1580
+ });
1581
+ if (values.json) {
1582
+ io.out(`${JSON.stringify({ entry }, null, 2)}\n`);
1583
+ return 0;
1584
+ }
1585
+ io.out(`quarantined ${entry.testId} until ${entry.expiresAt} (${entry.reason})\n`);
1586
+ return 0;
1587
+ } catch (e) {
1588
+ if (e instanceof QuarantineGateError) {
1589
+ io.err(`refused: ${e.message} (pass --allow-quarantine and --max-expiry-ms)\n`);
1590
+ return 1;
1591
+ }
1592
+ io.err(`${e.message}\n`);
1593
+ return 1;
1594
+ }
1595
+ }
1596
+ //#endregion
1597
+ //#region src/lsp.ts
1598
+ /**
1599
+ * `sackville lsp` — the human surface over `@sackville-mcp/lsp`. Single-shot semantic code
1600
+ * navigation: each invocation binds the operator's server registry, drives one query against
1601
+ * a live Language Server subprocess, then shuts it down.
1602
+ *
1603
+ * The human IS the operator, so the gates are straight-through flags: `--allow-run` (required
1604
+ * for any navigation — it spawns a code-executing indexing daemon, ADR 0011's
1605
+ * load-bearing gate) and `--allow-write` (lets `rename` write to disk; default = dry-run
1606
+ * preview). The typed `--project` root is the allowlist (explicit operator intent). Per ADR
1607
+ * 0011 the engine is **injectable** so the suite never spawns a real server — the production
1608
+ * path builds the real `LanguageServerManager`/`LspQueryEngine`/`LspRenameEngine` from flags
1609
+ * (mirroring `sackville-lsp-mcp`), and the gate throws *before* any spawn when `--allow-run` is
1610
+ * absent.
1611
+ *
1612
+ * Exit codes: 0 = the query ran (`ok`/`no_result`, or a rename preview/apply); 1 = denied,
1613
+ * refused, or error; 2 = `not_ready` (the server was still indexing — retry shortly).
1614
+ */
1615
+ async function runLsp(args, io, deps = {}) {
1616
+ const [sub, ...rest] = args;
1617
+ switch (sub) {
1618
+ case "languages": return cmdLanguages(rest, io, deps);
1619
+ case "definition": return cmdQuery("definition", rest, io, deps);
1620
+ case "type-definition": return cmdQuery("typeDefinition", rest, io, deps);
1621
+ case "references": return cmdQuery("references", rest, io, deps);
1622
+ case "hover": return cmdQuery("hover", rest, io, deps);
1623
+ case "symbols": return cmdQuery("documentSymbols", rest, io, deps);
1624
+ case "diagnostics": return cmdQuery("diagnostics", rest, io, deps);
1625
+ case "workspace-symbols": return cmdWorkspaceSymbols(rest, io, deps);
1626
+ case "call-hierarchy": return cmdQuery("callHierarchy", rest, io, deps);
1627
+ case "rename": return cmdRename(rest, io, deps);
1628
+ default:
1629
+ io.err(`unknown lsp subcommand: ${sub ?? "(none)"}\n`);
1630
+ return 1;
1631
+ }
1632
+ }
1633
+ const GATE_OPTIONS = {
1634
+ project: { type: "string" },
1635
+ "workspace-root": {
1636
+ type: "string",
1637
+ multiple: true
1638
+ },
1639
+ servers: { type: "string" },
1640
+ "allow-run": { type: "boolean" },
1641
+ "allow-write": { type: "boolean" },
1642
+ "allow-partial-rename": { type: "boolean" },
1643
+ "allow-destructive-resource-ops": { type: "boolean" },
1644
+ "timeout-ms": { type: "string" },
1645
+ json: { type: "boolean" }
1646
+ };
1647
+ function num$1(raw) {
1648
+ if (raw === void 0) return void 0;
1649
+ const n = Number(raw);
1650
+ return Number.isFinite(n) ? n : void 0;
1651
+ }
1652
+ /**
1653
+ * Build the query/rename engines — injected stubs in tests, else the real manager + engines
1654
+ * from the operator registry (`--servers`/`SACKVILLE_LSP_SERVERS`), gated by `--allow-run` /
1655
+ * `--allow-write` and confined to the `--project` root. Returns null on a config error (the
1656
+ * caller has already had the message written).
1657
+ */
1658
+ function makeEngines(values, io, deps) {
1659
+ const projectRoot = resolve(values.project ?? process.cwd());
1660
+ const workspaceRoots = (values["workspace-root"] ?? []).map((r) => resolve(r));
1661
+ if (values["allow-destructive-resource-ops"] && !values["allow-write"]) {
1662
+ io.err("--allow-destructive-resource-ops requires --allow-write\n");
1663
+ return null;
1664
+ }
1665
+ if (deps.query || deps.rename || deps.describeServers) return {
1666
+ query: deps.query ?? (async () => fail("query")),
1667
+ rename: deps.rename ?? (async () => fail("rename")),
1668
+ describeServers: deps.describeServers ?? (() => []),
1669
+ shutdown: async () => {},
1670
+ projectRoot,
1671
+ workspaceRoots
1672
+ };
1673
+ const raw = values.servers ?? io.env?.SACKVILLE_LSP_SERVERS;
1674
+ if (!raw || raw.trim() === "") {
1675
+ io.err("no servers bound: pass --servers <json> or set SACKVILLE_LSP_SERVERS\n");
1676
+ return null;
1677
+ }
1678
+ let registry;
1679
+ try {
1680
+ registry = parseServerRegistry(raw);
1681
+ } catch (e) {
1682
+ io.err(`invalid --servers registry: ${e.message}\n`);
1683
+ return null;
1684
+ }
1685
+ const allowedRoots = [projectRoot, ...workspaceRoots];
1686
+ const manager = new LanguageServerManager({
1687
+ registry,
1688
+ allowedRoots,
1689
+ timeoutMs: num$1(values["timeout-ms"]) ?? 15e3
1690
+ });
1691
+ const query = new LspQueryEngine({
1692
+ manager,
1693
+ allowRun: values["allow-run"] ?? false,
1694
+ allowedRoots
1695
+ });
1696
+ const rename = new LspRenameEngine({
1697
+ manager,
1698
+ allowRun: values["allow-run"] ?? false,
1699
+ allowedRoots,
1700
+ allowWrite: values["allow-write"] ?? false,
1701
+ allowPartialRename: values["allow-partial-rename"] ?? false,
1702
+ allowDestructiveResourceOps: values["allow-destructive-resource-ops"] ?? false,
1703
+ listFiles: defaultListFiles
1704
+ });
1705
+ return {
1706
+ query: (input) => query.query(input),
1707
+ rename: (input) => rename.rename(input),
1708
+ describeServers: () => manager.describe(),
1709
+ shutdown: () => manager.shutdown(),
1710
+ projectRoot,
1711
+ workspaceRoots
1712
+ };
1713
+ }
1714
+ function fail(what) {
1715
+ throw new Error(`no ${what} engine available`);
1716
+ }
1717
+ function cmdLanguages(args, io, deps) {
1718
+ const { values } = parseArgs({
1719
+ args,
1720
+ allowPositionals: true,
1721
+ options: GATE_OPTIONS
1722
+ });
1723
+ const raw = values.servers ?? io.env?.SACKVILLE_LSP_SERVERS;
1724
+ let languages = [];
1725
+ if (raw && raw.trim() !== "") try {
1726
+ languages = Object.keys(parseServerRegistry(raw)).sort();
1727
+ } catch (e) {
1728
+ io.err(`invalid --servers registry: ${e.message}\n`);
1729
+ return 1;
1730
+ }
1731
+ const servers = (deps.describeServers ?? (() => []))();
1732
+ if (values.json) {
1733
+ io.out(`${JSON.stringify({
1734
+ languages,
1735
+ servers
1736
+ }, null, 2)}\n`);
1737
+ return 0;
1738
+ }
1739
+ io.out(languages.length ? `bound languages: ${languages.join(", ")}\n` : "no languages bound\n");
1740
+ for (const s of servers) {
1741
+ const v = s.serverInfo ? `${s.serverInfo.name}${s.serverInfo.version ? ` ${s.serverInfo.version}` : ""}` : "(no serverInfo)";
1742
+ io.out(` ${s.language} @ ${s.projectRoot} ${v}\n`);
1743
+ }
1744
+ return 0;
1745
+ }
1746
+ /** Tri-state status → exit code (ok/no_result ran; not_ready is transient). */
1747
+ function statusExit(status) {
1748
+ return status === "not_ready" ? 2 : 0;
1749
+ }
1750
+ function rangeStr(r) {
1751
+ return `${r.start.line}:${r.start.column}-${r.end.line}:${r.end.column}`;
1752
+ }
1753
+ function printHeader(io, r) {
1754
+ const info = r.serverInfo ? `${r.serverInfo.name}${r.serverInfo.version ? ` ${r.serverInfo.version}` : ""}` : "unknown server";
1755
+ io.out(`status: ${r.status} [${r.kind}, ${r.encoding}, ${info}]\n`);
1756
+ if (r.versionWarning) io.err(`warning: ${r.versionWarning}\n`);
1757
+ }
1758
+ function printSymbols(io, symbols, depth) {
1759
+ for (const s of symbols) {
1760
+ const detail = s.detail ? ` ${s.detail}` : "";
1761
+ io.out(`${" ".repeat(depth + 1)}${s.name} [${s.kindName}] ${rangeStr(s.range)}${detail}\n`);
1762
+ if (s.children && s.children.length > 0) printSymbols(io, s.children, depth + 1);
1763
+ }
1764
+ }
1765
+ async function cmdQuery(kind, args, io, deps) {
1766
+ const { values, positionals } = parseArgs({
1767
+ args,
1768
+ allowPositionals: true,
1769
+ options: {
1770
+ ...GATE_OPTIONS,
1771
+ direction: { type: "string" }
1772
+ }
1773
+ });
1774
+ const positionLess = kind === "documentSymbols" || kind === "diagnostics";
1775
+ const [language, file, lineRaw, colRaw] = positionals;
1776
+ if (!language || !file || !positionLess && (lineRaw === void 0 || colRaw === void 0)) {
1777
+ io.err(positionLess ? `lsp ${kindCommand(kind)} needs <language> <file>\n` : `lsp ${kindCommand(kind)} needs <language> <file> <line> <column>\n`);
1778
+ return 1;
1779
+ }
1780
+ const engines = makeEngines(values, io, deps);
1781
+ if (!engines) return 1;
1782
+ try {
1783
+ const input = {
1784
+ language,
1785
+ projectRoot: engines.projectRoot,
1786
+ file,
1787
+ kind,
1788
+ ...engines.workspaceRoots.length ? { workspaceRoots: engines.workspaceRoots } : {},
1789
+ ...positionLess ? {} : {
1790
+ line: Number(lineRaw),
1791
+ column: Number(colRaw)
1792
+ },
1793
+ ...kind === "callHierarchy" ? { direction: values.direction ?? "incoming" } : {}
1794
+ };
1795
+ const result = await engines.query(input);
1796
+ if (values.json) {
1797
+ io.out(`${JSON.stringify(result, null, 2)}\n`);
1798
+ return statusExit(result.status);
1799
+ }
1800
+ printHeader(io, result);
1801
+ if (result.status === "not_ready") {
1802
+ io.out("the server is still indexing — retry shortly\n");
1803
+ return 2;
1804
+ }
1805
+ if (kind === "hover") io.out(result.hover ? `${result.hover.value}\n` : "no hover info\n");
1806
+ else if (kind === "documentSymbols") {
1807
+ const symbols = result.symbols ?? [];
1808
+ io.out(`${symbols.length} symbol(s):\n`);
1809
+ printSymbols(io, symbols, 0);
1810
+ } else if (kind === "diagnostics") {
1811
+ const diags = result.diagnostics ?? [];
1812
+ io.out(`${diags.length} diagnostic(s):\n`);
1813
+ for (const d of diags) printDiagnostic(io, d);
1814
+ } else if (kind === "callHierarchy") {
1815
+ const groups = result.callHierarchy ?? [];
1816
+ for (const g of groups) {
1817
+ io.out(`${g.source.name} [${g.source.kindName}] (${g.direction})\n`);
1818
+ for (const c of g.calls) io.out(` ${c.item.name} ${c.item.uri} ${c.fromRanges.map(rangeStr).join(", ")}\n`);
1819
+ }
1820
+ } else {
1821
+ const locations = result.locations ?? [];
1822
+ io.out(`${locations.length} location(s):\n`);
1823
+ for (const loc of locations) io.out(` ${loc.uri} ${rangeStr(loc.range)}${loc.mapped ? "" : " (unmapped)"}\n`);
1824
+ }
1825
+ return statusExit(result.status);
1826
+ } catch (e) {
1827
+ if (e instanceof LspGateError) {
1828
+ io.err(`refused: ${e.message} (pass --allow-run)\n`);
1829
+ return 1;
1830
+ }
1831
+ io.err(`${e.message}\n`);
1832
+ return 1;
1833
+ } finally {
1834
+ await engines.shutdown();
1835
+ }
1836
+ }
1837
+ /**
1838
+ * `workspace-symbols <language> <query> [anchorFile]` — position-less project-wide symbol search.
1839
+ * The optional `[anchorFile]` is opened first to establish the project; pass it for servers (like
1840
+ * `typescript-language-server`) that only build a project once a file is open.
1841
+ */
1842
+ async function cmdWorkspaceSymbols(args, io, deps) {
1843
+ const { values, positionals } = parseArgs({
1844
+ args,
1845
+ allowPositionals: true,
1846
+ options: GATE_OPTIONS
1847
+ });
1848
+ const [language, query, anchorFile] = positionals;
1849
+ if (!language || query === void 0) {
1850
+ io.err("lsp workspace-symbols needs <language> <query> [anchorFile]\n");
1851
+ return 1;
1852
+ }
1853
+ const engines = makeEngines(values, io, deps);
1854
+ if (!engines) return 1;
1855
+ try {
1856
+ const result = await engines.query({
1857
+ language,
1858
+ projectRoot: engines.projectRoot,
1859
+ kind: "workspaceSymbol",
1860
+ query,
1861
+ ...engines.workspaceRoots.length ? { workspaceRoots: engines.workspaceRoots } : {},
1862
+ ...anchorFile !== void 0 ? { file: anchorFile } : {}
1863
+ });
1864
+ if (values.json) {
1865
+ io.out(`${JSON.stringify(result, null, 2)}\n`);
1866
+ return statusExit(result.status);
1867
+ }
1868
+ printHeader(io, result);
1869
+ if (result.status === "not_ready") {
1870
+ io.out("the server is still indexing — retry shortly\n");
1871
+ return 2;
1872
+ }
1873
+ const symbols = result.workspaceSymbols ?? [];
1874
+ io.out(`${symbols.length} symbol(s):\n`);
1875
+ for (const s of symbols) printWorkspaceSymbol(io, s);
1876
+ return statusExit(result.status);
1877
+ } catch (e) {
1878
+ if (e instanceof LspGateError) {
1879
+ io.err(`refused: ${e.message} (pass --allow-run)\n`);
1880
+ return 1;
1881
+ }
1882
+ io.err(`${e.message}\n`);
1883
+ return 1;
1884
+ } finally {
1885
+ await engines.shutdown();
1886
+ }
1887
+ }
1888
+ function printDiagnostic(io, d) {
1889
+ const sev = d.severityName ?? (d.severity !== void 0 ? `severity ${d.severity}` : "Diagnostic");
1890
+ const where = `${d.range.start.line}:${d.range.start.column}`;
1891
+ const code = d.code !== void 0 ? ` [${d.source ? `${d.source} ` : ""}${d.code}]` : "";
1892
+ io.out(` ${where} ${sev}${code} ${d.message}\n`);
1893
+ for (const r of d.related ?? []) io.out(` ↳ ${r.uri} ${r.range.start.line}:${r.range.start.column} ${r.message}\n`);
1894
+ }
1895
+ function printWorkspaceSymbol(io, s) {
1896
+ const container = s.container ? ` (in ${s.container})` : "";
1897
+ const loc = s.range ? ` ${rangeStr(s.range)}${s.mapped ? "" : " (unmapped)"}` : "";
1898
+ io.out(` ${s.name} [${s.kindName}] ${s.uri}${loc}${container}\n`);
1899
+ }
1900
+ /** Map a query kind back to its CLI subcommand name (for error messages). */
1901
+ function kindCommand(kind) {
1902
+ if (kind === "typeDefinition") return "type-definition";
1903
+ if (kind === "documentSymbols") return "symbols";
1904
+ if (kind === "callHierarchy") return "call-hierarchy";
1905
+ return kind;
1906
+ }
1907
+ async function cmdRename(args, io, deps) {
1908
+ const { values, positionals } = parseArgs({
1909
+ args,
1910
+ allowPositionals: true,
1911
+ options: GATE_OPTIONS
1912
+ });
1913
+ const [language, file, lineRaw, colRaw, newName] = positionals;
1914
+ if (!language || !file || lineRaw === void 0 || colRaw === void 0 || !newName) {
1915
+ io.err("lsp rename needs <language> <file> <line> <column> <newName>\n");
1916
+ return 1;
1917
+ }
1918
+ const engines = makeEngines(values, io, deps);
1919
+ if (!engines) return 1;
1920
+ try {
1921
+ const result = await engines.rename({
1922
+ language,
1923
+ projectRoot: engines.projectRoot,
1924
+ file,
1925
+ line: Number(lineRaw),
1926
+ column: Number(colRaw),
1927
+ newName,
1928
+ ...engines.workspaceRoots.length ? { workspaceRoots: engines.workspaceRoots } : {}
1929
+ });
1930
+ if (values.json) io.out(`${JSON.stringify(result, null, 2)}\n`);
1931
+ else printRename(io, result);
1932
+ if (result.status === "not_ready") return 2;
1933
+ if (result.refused) return 1;
1934
+ return 0;
1935
+ } catch (e) {
1936
+ if (e instanceof LspGateError) {
1937
+ io.err(`refused: ${e.message} (pass --allow-run)\n`);
1938
+ return 1;
1939
+ }
1940
+ io.err(`${e.message}\n`);
1941
+ return 1;
1942
+ } finally {
1943
+ await engines.shutdown();
1944
+ }
1945
+ }
1946
+ function printRename(io, r) {
1947
+ const info = r.serverInfo ? `${r.serverInfo.name}${r.serverInfo.version ? ` ${r.serverInfo.version}` : ""}` : "unknown server";
1948
+ const mode = r.applied ? "APPLIED to disk" : "dry-run (not applied)";
1949
+ io.out(`status: ${r.status} ${mode} [${r.encoding}, ${info}]\n`);
1950
+ if (r.versionWarning) io.err(`warning: ${r.versionWarning}\n`);
1951
+ if (r.completeness === "suspect") {
1952
+ const miss = r.suspectedMissedFiles ?? [];
1953
+ io.err(`warning: rename may be INCOMPLETE — the symbol also appears in ${miss.length} same-language file(s) NOT in this edit: ${miss.join(", ")}\n`);
1954
+ io.err(" the language server may scope rename to open files; re-run with --allow-partial-rename to apply anyway\n");
1955
+ } else if (r.completeness === "unknown") io.err("warning: rename completeness unverified (file scan was truncated)\n");
1956
+ if (r.refused) {
1957
+ io.err(`refused: ${r.refused}\n`);
1958
+ return;
1959
+ }
1960
+ io.out(`rename → ${r.newName}: ${r.totalEditCount} edit(s) across ${r.fileCount} file(s)\n`);
1961
+ for (const f of r.edits) {
1962
+ io.out(` ${f.file} ${f.editCount} edit(s)${f.outOfRoot ? " (out of project root)" : ""}\n`);
1963
+ for (const h of f.hunks ?? []) io.out(` ${rangeStr(h.range)} ${h.oldText} → ${h.newText}\n`);
1964
+ }
1965
+ for (const op of r.resourceOps ?? []) io.out(` ${op.kind} file: ${op.uris.join(" → ")}\n`);
1966
+ if (r.overwritten?.length) io.err(`warning: DESTRUCTIVELY overwrote ${r.overwritten.length} existing file(s): ${r.overwritten.join(", ")}\n`);
1967
+ if (r.partial) io.err(`warning: PARTIAL apply (no rollback — reconcile via VCS): ${r.partialError ?? ""}\n`);
1968
+ for (const d of r.digests ?? []) io.out(` digest ${d.file}: ${d.before.slice(0, 12)} → ${d.after.slice(0, 12) || "(deleted)"}\n`);
1969
+ }
1970
+ //#endregion
1971
+ //#region src/mutate.ts
1972
+ /**
1973
+ * `sackville mutate` — the human surface over `@sackville-mcp/mutate`.
1974
+ *
1975
+ * `summarize` is a pure report viewer (Stryker JSON or `mutmut results` text). `run` is the
1976
+ * gated, diff-scopable mutation run. The CLI's human IS the operator, so the run gate is a
1977
+ * straight-through `--allow-run` flag (mirroring `sackville api --unsafe`): the typed project
1978
+ * root is auto-allowed (explicit operator intent). The `runner` is injectable so the suite
1979
+ * never spawns a real Stryker (ADR 0010: no real spawn in the gate).
1980
+ */
1981
+ async function runMutate(args, io, deps = {}) {
1982
+ const [sub, ...rest] = args;
1983
+ switch (sub) {
1984
+ case "summarize": return cmdSummarize(rest, io);
1985
+ case "run": return cmdRun(rest, io, deps);
1986
+ default:
1987
+ io.err(`unknown mutate subcommand: ${sub ?? "(none)"}\n`);
1988
+ return 1;
1989
+ }
1990
+ }
1991
+ /** Format a percent metric (`null` ⇒ not applicable, e.g. zero valid mutants). */
1992
+ function pct(value) {
1993
+ return value === null ? "n/a" : `${value.toFixed(1)}%`;
1994
+ }
1995
+ function printSummary(io, summary) {
1996
+ const { metrics, survivors } = summary;
1997
+ const c = metrics.counts;
1998
+ io.out(`mutation score: ${pct(metrics.mutationScore)} (detected ${metrics.detected} / valid ${metrics.valid})\n`);
1999
+ io.out(`covered-code score: ${pct(metrics.mutationScoreBasedOnCoveredCode)}\n`);
2000
+ io.out(`killed ${c.killed} survived ${c.survived} timeout ${c.timeout} no-coverage ${c.noCoverage} compile-errors ${c.compileErrors} runtime-errors ${c.runtimeErrors} ignored ${c.ignored} pending ${c.pending}\n`);
2001
+ if (survivors.length === 0) {
2002
+ io.out("survivors: (none)\n");
2003
+ return;
2004
+ }
2005
+ io.out(`survivors (${survivors.length}):\n`);
2006
+ for (const s of survivors) io.out(` ${s.file}:${s.line} ${s.mutatorName} [${s.status}]\n`);
2007
+ }
2008
+ function cmdSummarize(args, io) {
2009
+ const { values, positionals } = parseArgs({
2010
+ args,
2011
+ allowPositionals: true,
2012
+ options: {
2013
+ format: { type: "string" },
2014
+ json: { type: "boolean" }
2015
+ }
2016
+ });
2017
+ const reportFile = positionals[0];
2018
+ if (!reportFile) {
2019
+ io.err("mutate summarize needs a <report-file>\n");
2020
+ return 1;
2021
+ }
2022
+ const format = values.format ?? "stryker";
2023
+ if (format !== "stryker" && format !== "mutmut") {
2024
+ io.err(`unknown report format: ${format} (expected stryker|mutmut)\n`);
2025
+ return 1;
2026
+ }
2027
+ const text = readFileSync(reportFile, "utf8");
2028
+ const summary = summarizeMutation(format === "mutmut" ? parseMutmutResults(text) : JSON.parse(text));
2029
+ if (values.json) {
2030
+ io.out(`${JSON.stringify(summary, null, 2)}\n`);
2031
+ return 0;
2032
+ }
2033
+ printSummary(io, summary);
2034
+ return 0;
2035
+ }
2036
+ async function cmdRun(args, io, deps) {
2037
+ const { values, positionals } = parseArgs({
2038
+ args,
2039
+ allowPositionals: true,
2040
+ options: {
2041
+ tool: { type: "string" },
2042
+ file: {
2043
+ type: "string",
2044
+ multiple: true
2045
+ },
2046
+ incremental: { type: "boolean" },
2047
+ "config-path": { type: "string" },
2048
+ "allow-run": { type: "boolean" },
2049
+ "timeout-ms": { type: "string" },
2050
+ "report-path": { type: "string" },
2051
+ json: { type: "boolean" }
2052
+ }
2053
+ });
2054
+ const projectRoot = positionals[0];
2055
+ if (!projectRoot) {
2056
+ io.err("mutate run needs a <project-root>\n");
2057
+ return 1;
2058
+ }
2059
+ const tool = values.tool ?? "stryker";
2060
+ if (tool !== "stryker" && tool !== "mutmut" && tool !== "cosmic-ray") {
2061
+ io.err(`unknown tool: ${tool} (expected stryker|mutmut|cosmic-ray)\n`);
2062
+ return 1;
2063
+ }
2064
+ const timeoutRaw = values["timeout-ms"];
2065
+ const timeoutMs = timeoutRaw !== void 0 ? Number(timeoutRaw) : void 0;
2066
+ try {
2067
+ const config = {
2068
+ projectRoot,
2069
+ allowedRoots: [resolve(projectRoot)],
2070
+ allowRun: values["allow-run"] ?? false,
2071
+ timeoutMs: timeoutMs !== void 0 && Number.isFinite(timeoutMs) ? timeoutMs : void 0
2072
+ };
2073
+ const input = {
2074
+ mutateFiles: values.file,
2075
+ incremental: values.incremental ?? false,
2076
+ configPath: values["config-path"]
2077
+ };
2078
+ const result = tool === "mutmut" ? await runMutmut(config, input, { runner: deps.runner }) : tool === "cosmic-ray" ? await runCosmicRay(config, input, { runner: deps.runner }) : await runMutation(config, input, {
2079
+ runner: deps.runner,
2080
+ reportPath: values["report-path"]
2081
+ });
2082
+ if (values.json) {
2083
+ io.out(`${JSON.stringify(result, null, 2)}\n`);
2084
+ return result.exitCode === 0 ? 0 : 1;
2085
+ }
2086
+ io.out(`ran ${tool} (exit ${result.exitCode}); scoped: ${result.scopedFiles.join(", ") || "(project default)"}\n`);
2087
+ printSummary(io, result.summary);
2088
+ return result.exitCode === 0 ? 0 : 1;
2089
+ } catch (e) {
2090
+ if (e instanceof MutateGateError) {
2091
+ io.err(`refused: ${e.message} (pass --allow-run)\n`);
2092
+ return 1;
2093
+ }
2094
+ io.err(`${e.message}\n`);
2095
+ return 1;
2096
+ }
2097
+ }
2098
+ //#endregion
2099
+ //#region src/verify.ts
2100
+ const SEVERITIES = [
2101
+ "critical",
2102
+ "high",
2103
+ "moderate",
2104
+ "low",
2105
+ "none"
2106
+ ];
2107
+ const EMPTY_COVERAGE = {
2108
+ files: [],
2109
+ uncovered: [],
2110
+ summary: {
2111
+ covered: 0,
2112
+ uncovered: 0,
2113
+ nonExecutable: 0,
2114
+ total: 0,
2115
+ filesWithoutCoverage: 0
2116
+ }
2117
+ };
2118
+ /**
2119
+ * `sackville verify` — the human surface over the cross-pillar verdict.
2120
+ *
2121
+ * - `verify [--contract f] [--coverage f] ...` (COMPOSE): fold per-pillar JSON results
2122
+ * on disk into one verdict (ADR 0013 §1). The human supplies each pillar's output.
2123
+ * - `verify run <root> [--coverage] [--flake --flake-db f] [--mutate [--mutate-tool T --mutate-config f]] [--deps] [--allow-run] ...`
2124
+ * (RUN-DRIVING, ADR 0013 Addendum 5c/5d): DRIVE the selected pillars and fold them. The
2125
+ * human is the operator, so `--allow-run` is the straight-through gate for the SPAWN
2126
+ * pillars (coverage/flake/mutate) and the typed root is auto-allowed; each pillar's own
2127
+ * `assertAllowed` still denies without it (⇒ `skipReason:gate-not-set`, never run —
2128
+ * "compose, never widen"). `--mutate-tool` picks the mutation engine (stryker default |
2129
+ * cosmic-ray | mutmut; the Python tools diff-scope via a synthesized config from
2130
+ * `--mutate-config`, ADR 0010 addendum 2). `--deps` is gated by NETWORK not spawn (a packument fetch),
2131
+ * so it needs no `--allow-run`; a `--diff` scopes the audit to the changed packages
2132
+ * (`changedDependencies`). `--flow <name>` (5e) DRIVES an operator-authored browser flow
2133
+ * to capture a HAR and validate it against `--openapi`/`--graphql` — gated by the browser
2134
+ * egress flags (`--flows-dir`/`--allow-host` + the mandatory SSRF proxy), not `--allow-run`.
2135
+ * Runners are injectable so the suite never spawns (ADR 0010).
2136
+ *
2137
+ * Exit codes (both modes): 0 pass / 1 fail|warn / 2 inconclusive.
2138
+ */
2139
+ async function runVerify(args, io, deps = {}) {
2140
+ if (args[0] === "run") return cmdVerifyRun(args.slice(1), io, deps);
2141
+ return runVerifyCompose(args, io);
2142
+ }
2143
+ function printVerdict(io, verdict, json) {
2144
+ if (json) {
2145
+ io.out(`${JSON.stringify(verdict, null, 2)}\n`);
2146
+ return;
2147
+ }
2148
+ io.out(`verdict: ${verdict.status.toUpperCase()} (worst severity ${verdict.worstSeverity})\n`);
2149
+ for (const p of verdict.pillars) {
2150
+ const sev = p.severity !== "none" ? ` [${p.severity}]` : "";
2151
+ const why = p.skipReason ? ` (skipped: ${p.skipReason})` : p.errorReason ? " (errored)" : "";
2152
+ io.out(` ${p.pillar}: ${p.status}${sev}${why} — ${p.headline}\n`);
2153
+ }
2154
+ }
2155
+ function exitFor(verdict) {
2156
+ if (verdict.status === "pass") return 0;
2157
+ if (verdict.status === "inconclusive") return 2;
2158
+ return 1;
2159
+ }
2160
+ function num(value) {
2161
+ if (value === void 0) return void 0;
2162
+ const n = Number(value);
2163
+ return Number.isFinite(n) ? n : void 0;
2164
+ }
2165
+ async function cmdVerifyRun(args, io, deps) {
2166
+ const { values, positionals } = parseArgs({
2167
+ args,
2168
+ allowPositionals: true,
2169
+ options: {
2170
+ coverage: { type: "boolean" },
2171
+ flake: { type: "boolean" },
2172
+ mutate: { type: "boolean" },
2173
+ "mutate-tool": { type: "string" },
2174
+ "mutate-config": { type: "string" },
2175
+ deps: { type: "boolean" },
2176
+ "allow-run": { type: "boolean" },
2177
+ "changed-file": {
2178
+ type: "string",
2179
+ multiple: true
2180
+ },
2181
+ diff: { type: "string" },
2182
+ "flake-db": { type: "string" },
2183
+ "osv-db": { type: "string" },
2184
+ registry: { type: "string" },
2185
+ "allow-private": { type: "boolean" },
2186
+ flow: { type: "string" },
2187
+ "flows-dir": { type: "string" },
2188
+ request: { type: "string" },
2189
+ "collection-dir": { type: "string" },
2190
+ "allow-unsafe": { type: "boolean" },
2191
+ "allow-host": {
2192
+ type: "string",
2193
+ multiple: true
2194
+ },
2195
+ var: {
2196
+ type: "string",
2197
+ multiple: true
2198
+ },
2199
+ openapi: { type: "string" },
2200
+ graphql: { type: "string" },
2201
+ "graphql-endpoint": { type: "string" },
2202
+ engine: { type: "string" },
2203
+ "no-sandbox": { type: "boolean" },
2204
+ headed: { type: "boolean" },
2205
+ "timeout-ms": { type: "string" },
2206
+ "fail-at-or-above": { type: "string" },
2207
+ json: { type: "boolean" }
2208
+ }
2209
+ });
2210
+ const projectRoot = positionals[0];
2211
+ if (!projectRoot) {
2212
+ io.err("verify run needs a <project-root>\n");
2213
+ return 2;
2214
+ }
2215
+ const failAtOrAbove = values["fail-at-or-above"];
2216
+ if (failAtOrAbove !== void 0 && !SEVERITIES.includes(failAtOrAbove)) {
2217
+ io.err(`--fail-at-or-above must be one of ${SEVERITIES.join("|")}\n`);
2218
+ return 2;
2219
+ }
2220
+ const allowRun = values["allow-run"] ?? false;
2221
+ const allowedRoots = [resolve(projectRoot)];
2222
+ const changedFiles = values["changed-file"] ?? [];
2223
+ const diff = values.diff !== void 0 ? readFileSync(values.diff, "utf8") : void 0;
2224
+ const timeoutMs = num(values["timeout-ms"]);
2225
+ const ctx = {
2226
+ projectRoot,
2227
+ changedFiles,
2228
+ diff
2229
+ };
2230
+ const request = {};
2231
+ if (values.coverage) {
2232
+ const ovr = deps.coverage;
2233
+ request.coverage = { run: ovr ? () => ovr(ctx) : async () => {
2234
+ return (await runScoped({
2235
+ projectRoot,
2236
+ allowedRoots,
2237
+ allowRun,
2238
+ timeoutMs
2239
+ }, {
2240
+ changedFiles,
2241
+ diff
2242
+ }, { runner: deps.coverageRunner })).report ?? EMPTY_COVERAGE;
2243
+ } };
2244
+ }
2245
+ if (values.mutate) {
2246
+ const ovr = deps.mutate;
2247
+ const tool = values["mutate-tool"] ?? "stryker";
2248
+ if (tool !== "stryker" && tool !== "cosmic-ray" && tool !== "mutmut") {
2249
+ io.err(`verify run --mutate-tool must be stryker | cosmic-ray | mutmut (got ${tool})\n`);
2250
+ return 2;
2251
+ }
2252
+ const configPath = values["mutate-config"];
2253
+ request.mutate = { run: ovr ? () => ovr(ctx) : async () => {
2254
+ const cfg = {
2255
+ projectRoot,
2256
+ allowedRoots,
2257
+ allowRun,
2258
+ timeoutMs
2259
+ };
2260
+ const mInput = {
2261
+ mutateFiles: changedFiles,
2262
+ configPath
2263
+ };
2264
+ return (tool === "cosmic-ray" ? await runCosmicRay(cfg, mInput, { runner: deps.mutateRunner }) : tool === "mutmut" ? await runMutmut(cfg, mInput, { runner: deps.mutateRunner }) : await runMutation(cfg, mInput, { runner: deps.mutateRunner })).summary;
2265
+ } };
2266
+ }
2267
+ if (values.flake) {
2268
+ const ovr = deps.flake;
2269
+ if (ovr) request.flake = { run: () => ovr(ctx) };
2270
+ else {
2271
+ const dbPath = values["flake-db"];
2272
+ const store = deps.historyStore ?? (dbPath ? HistoryStore.open(dbPath) : void 0);
2273
+ if (!store) {
2274
+ io.err("verify run --flake needs --flake-db <path>\n");
2275
+ return 2;
2276
+ }
2277
+ request.flake = { run: async () => {
2278
+ try {
2279
+ return (await runAndRecord(store, {
2280
+ projectRoot,
2281
+ allowedRoots,
2282
+ allowRun,
2283
+ timeoutMs
2284
+ }, { files: changedFiles }, { runner: deps.flakeRunner })).verdicts;
2285
+ } finally {
2286
+ if (!deps.historyStore && dbPath) store.close();
2287
+ }
2288
+ } };
2289
+ }
2290
+ }
2291
+ if (values.deps) {
2292
+ const ovr = deps.deps;
2293
+ if (ovr) request.deps = { run: () => ovr(ctx) };
2294
+ else {
2295
+ const ecosystem = "npm";
2296
+ const fetchPackument = deps.depsFetcher ?? makeFetcher(registriesFrom(values));
2297
+ const osvDir = values["osv-db"];
2298
+ request.deps = { run: async () => {
2299
+ const scoped = diff ? changedDependencies(diff, ecosystem) : [];
2300
+ const { audits, osvSnapshotLoaded } = await auditProjectScoped({
2301
+ project: projectRoot,
2302
+ ecosystem,
2303
+ names: scoped.length > 0 ? scoped : void 0,
2304
+ osvDir,
2305
+ fetchPackument
2306
+ });
2307
+ return {
2308
+ audits,
2309
+ osvSnapshotLoaded
2310
+ };
2311
+ } };
2312
+ }
2313
+ }
2314
+ if (values.request && values.flow) {
2315
+ io.err("verify run: --request and --flow are mutually exclusive\n");
2316
+ return 2;
2317
+ }
2318
+ if (values.request) {
2319
+ const requestName = values.request;
2320
+ const vars = parseVars(values.var);
2321
+ const ovr = deps.contractApi;
2322
+ if (ovr) request.contract = {
2323
+ source: "capture-from-HAR",
2324
+ run: () => ovr({
2325
+ request: requestName,
2326
+ collectionDir: values["collection-dir"],
2327
+ vars
2328
+ })
2329
+ };
2330
+ else {
2331
+ const colDir = values["collection-dir"];
2332
+ if (!colDir) {
2333
+ io.err("verify run --request needs --collection-dir <dir>\n");
2334
+ return 2;
2335
+ }
2336
+ const contract = readCaptureContract(values);
2337
+ const store = new ArtifactStore$2(mkdtempSync(join(tmpdir(), "sackville-verify-cap-")), "verify");
2338
+ request.contract = {
2339
+ source: "capture-from-HAR",
2340
+ run: async () => {
2341
+ return (await runRequestToHar(loadCollection(colDir), requestName, {
2342
+ vars,
2343
+ allowUnsafe: values["allow-unsafe"] ?? false,
2344
+ allowedHosts: values["allow-host"] ?? []
2345
+ }, {
2346
+ store,
2347
+ redactor: new Redactor$1(),
2348
+ contract
2349
+ })).verdict;
2350
+ }
2351
+ };
2352
+ }
2353
+ }
2354
+ if (values.flow) {
2355
+ const flow = values.flow;
2356
+ const vars = parseVars(values.var);
2357
+ const ovr = deps.contract;
2358
+ if (ovr) request.contract = {
2359
+ source: "capture-from-HAR",
2360
+ run: () => ovr({
2361
+ flow,
2362
+ vars
2363
+ })
2364
+ };
2365
+ else {
2366
+ if (!values["flows-dir"]) {
2367
+ io.err("verify run --flow needs --flows-dir <dir>\n");
2368
+ return 2;
2369
+ }
2370
+ const flowsDir = values["flows-dir"];
2371
+ const contract = readCaptureContract(values);
2372
+ const store = new ArtifactStore$2(mkdtempSync(join(tmpdir(), "sackville-verify-cap-")), "verify");
2373
+ request.contract = {
2374
+ source: "capture-from-HAR",
2375
+ run: async () => {
2376
+ const { harHandle } = await driveBrowserFlowToHar({
2377
+ flow,
2378
+ vars
2379
+ }, {
2380
+ runtimeFactory: () => captureRuntimeFromFlags(values),
2381
+ store,
2382
+ flowsDir
2383
+ });
2384
+ const har = store.get(harHandle)?.body;
2385
+ if (!har) throw new Error("no HAR was captured for the driven flow");
2386
+ return validateCapturedTraffic(har, contract, {}).results;
2387
+ }
2388
+ };
2389
+ }
2390
+ }
2391
+ if (Object.keys(request).length === 0) {
2392
+ io.err("verify run needs ≄1 pillar (--coverage / --flake / --mutate / --deps / --flow / --request)\n");
2393
+ return 2;
2394
+ }
2395
+ const { verdict } = await orchestrate(request, { policy: { failAtOrAbove } });
2396
+ printVerdict(io, verdict, values.json);
2397
+ return exitFor(verdict);
2398
+ }
2399
+ /** Parse repeated `--var k=v` flags into a map (non-secret flow vars). */
2400
+ function parseVars(pairs) {
2401
+ const vars = {};
2402
+ for (const p of pairs ?? []) {
2403
+ const i = p.indexOf("=");
2404
+ if (i > 0) vars[p.slice(0, i)] = p.slice(i + 1);
2405
+ }
2406
+ return vars;
2407
+ }
2408
+ /** Build the capture→contract from the openapi/graphql flags (≄1 needed, like the MCP tool). */
2409
+ function readCaptureContract(values) {
2410
+ const contract = {};
2411
+ if (values.openapi) contract.openapi = JSON.parse(readFileSync(values.openapi, "utf8"));
2412
+ if (values.graphql) contract.graphql = {
2413
+ endpointPath: values["graphql-endpoint"] ?? "/graphql",
2414
+ sdl: readFileSync(values.graphql, "utf8")
2415
+ };
2416
+ return contract;
2417
+ }
2418
+ /** Build a single-shot CaptureRuntime from the CLI's browser egress flags (mirrors
2419
+ * `sackville browser`): a gated, proxy-fronted manager with HAR recording armed. */
2420
+ async function captureRuntimeFromFlags(values) {
2421
+ const gate = new BrowserGate({ allowedHosts: values["allow-host"] ?? [] });
2422
+ const proxy = await createSsrfProxy({ allowPrivate: values["allow-private"] ?? false });
2423
+ const harDir = mkdtempSync(join(tmpdir(), "sackville-verify-har-"));
2424
+ const manager = new BrowserManager({
2425
+ gate,
2426
+ harDir,
2427
+ launch: engineLauncher(resolveEngine(values.engine), {
2428
+ headless: !values.headed,
2429
+ proxyServer: proxy.url,
2430
+ noSandbox: values["no-sandbox"] ?? false
2431
+ })
2432
+ });
2433
+ return {
2434
+ manager,
2435
+ gate,
2436
+ redact: (s) => s,
2437
+ config: { harDir },
2438
+ shutdown: async () => {
2439
+ await manager.shutdown();
2440
+ await proxy.close();
2441
+ }
2442
+ };
2443
+ }
2444
+ function runVerifyCompose(args, io) {
2445
+ const { values } = parseArgs({
2446
+ args,
2447
+ options: {
2448
+ contract: { type: "string" },
2449
+ source: { type: "string" },
2450
+ coverage: { type: "string" },
2451
+ deps: { type: "string" },
2452
+ "osv-snapshot-loaded": { type: "boolean" },
2453
+ flake: { type: "string" },
2454
+ mutate: { type: "string" },
2455
+ "fail-at-or-above": { type: "string" },
2456
+ json: { type: "boolean" }
2457
+ }
2458
+ });
2459
+ const failAtOrAbove = values["fail-at-or-above"];
2460
+ if (failAtOrAbove !== void 0 && !SEVERITIES.includes(failAtOrAbove)) {
2461
+ io.err(`--fail-at-or-above must be one of ${SEVERITIES.join("|")}\n`);
2462
+ return 2;
2463
+ }
2464
+ const readJson = (p) => JSON.parse(readFileSync(p, "utf8"));
2465
+ const inputs = {};
2466
+ if (values.contract) {
2467
+ const c = readJson(values.contract);
2468
+ inputs.contract = fromContractResults(Array.isArray(c) ? c : c.results ?? [], values.source === "run" ? "run" : "capture-from-HAR");
2469
+ }
2470
+ if (values.coverage) inputs.coverage = fromDiffCoverage(readJson(values.coverage));
2471
+ if (values.deps) inputs.deps = fromDependencyAudits(readJson(values.deps), { osvSnapshotLoaded: values["osv-snapshot-loaded"] ?? false });
2472
+ if (values.flake) inputs.flake = fromFlakeVerdicts(readJson(values.flake));
2473
+ if (values.mutate) inputs.mutate = fromMutationSummary(readJson(values.mutate));
2474
+ const verdict = composeVerdict(inputs, { failAtOrAbove });
2475
+ printVerdict(io, verdict, values.json);
2476
+ return exitFor(verdict);
2477
+ }
2478
+ //#endregion
2479
+ //#region src/index.ts
2480
+ const HELP = `sackville — version-pinned documentation search
2481
+
2482
+ Usage:
2483
+ sackville search <query…> [-l <lib>] [--version <v>] [--installed <v>] [-p <dir>] [--ecosystem <e>] [--type <t>] [--limit <n>] [--json]
2484
+ sackville get <id> [--json]
2485
+ sackville versions <library>
2486
+ sackville detect <project> <library> [--ecosystem <node|python|ruby>]
2487
+
2488
+ API testing:
2489
+ sackville api list <dir> [--json]
2490
+ sackville api get <dir> <name> [--json]
2491
+ sackville api run <dir> <name> [--var k=v…] [--env <e>] [--unsafe] [--allow-host <h>…] [--keyring] [--block-private] [--max-redirects <n>] [--openapi <spec.json>] [--json]
2492
+ sackville api run-collection <dir> <name…> [--var k=v…] [--env <e>] [--unsafe] [--allow-host <h>…] [--keyring] [--block-private] [--max-redirects <n>] [--stop-on-failure] [--json]
2493
+ sackville api validate --graphql <schema> --query <q> [--operation <name>] [--json]
2494
+ sackville api import <postman|insomnia|openapi|har> <source-file> <dest-dir> [--name <n>]
2495
+
2496
+ Browser testing (single-shot; the typed host is auto-allowed):
2497
+ sackville browser snapshot <url> [--allow-host <h>…] [--allow-private] [--no-sandbox] [--headed] [--engine chromium|firefox|webkit] [--json]
2498
+ sackville browser audit <url> [same flags] (exit 1 if any a11y violations)
2499
+ sackville browser screenshot <url> [--out <file>] [--full-page] [same flags]
2500
+ sackville browser run <flow.bru> [--var k=v…] [--unsafe] [--allow-host <h>…] [same flags] (replay a persisted flow; exit 1 on failure)
2501
+
2502
+ Mutation testing:
2503
+ sackville mutate summarize <report-file> [--format stryker|mutmut] [--json]
2504
+ sackville mutate run <project-root> [--file <f>…] [--incremental] [--allow-run] [--timeout-ms <n>] [--report-path <p>] [--json] (gated; needs --allow-run)
2505
+
2506
+ Coverage (impact-scoped; exit 1 when a new line is uncovered):
2507
+ sackville coverage uncovered-in-diff --diff <file> --coverage <file> [--coverage-format istanbul|coveragepy] [--project-root <p>] [--json]
2508
+ sackville coverage run-scoped <project-root> --changed-file <f>… [--diff <file>] [--allow-run] [--timeout-ms <n>] [--json] (gated; needs --allow-run)
2509
+
2510
+ Flaky-test detection (--db <run-history.db> or SACKVILLE_FLAKE_DB):
2511
+ sackville flake status [--min-runs <n>] [--limit-per-test <n>] [--since <ISO>] [--json]
2512
+ sackville flake candidates [--min-flake-score <0..1>] [--min-runs <n>] [--json]
2513
+ sackville flake ingest <report-file> [--format vitest|pytest] [--at <ISO>] [--project-root <p>] [--run-group <g>] [--json]
2514
+ sackville flake release <testId>
2515
+ sackville flake run <project-root> [--repeat <n>] [--file <f>…] [--run-group <g>] [--allow-run] [--timeout-ms <n>] [--json] (gated)
2516
+ sackville flake quarantine <testId> --reason <r> --expires-at <ISO> [--flake-score <s>] [--allow-quarantine] [--max-expiry-ms <n>] [--json] (gated write)
2517
+
2518
+ Dependency/version intelligence (for the INSTALLED version; exit 1 on a finding):
2519
+ sackville deps audit <project> <package> [--ecosystem npm|PyPI|RubyGems] [--version <v>] [--osv-db <dir>] [--registry <url>] [--allow-private] [--json]
2520
+ sackville deps audit-project <project> [--ecosystem <e>] [--skip-dev] [--osv-db <dir>] [--registry <url>] [--allow-private] [--json]
2521
+ sackville deps changelog <package> (--from <v> | --project <dir>) [--to <v>] [--ecosystem <e>] [--registry <url>] [--json]
2522
+
2523
+ Semantic code navigation (LSP; single-shot; --servers <json> or SACKVILLE_LSP_SERVERS binds the server registry):
2524
+ sackville lsp languages [--servers <json>] [--json]
2525
+ sackville lsp definition|type-definition|references|hover <lang> <file> <line> <col> --project <dir> --allow-run [--servers <json>] [--timeout-ms <n>] [--json]
2526
+ sackville lsp symbols <lang> <file> --project <dir> --allow-run [--servers <json>] [--json]
2527
+ sackville lsp call-hierarchy <lang> <file> <line> <col> --project <dir> --allow-run [--direction incoming|outgoing] [--json]
2528
+ sackville lsp rename <lang> <file> <line> <col> <newName> --project <dir> --allow-run [--allow-write] [--allow-partial-rename] [--json] (dry-run unless --allow-write)
2529
+ (exit 2 = server still indexing, retry; a "suspect" rename — an open-files-scoped server's likely-partial edit — is refused for write unless --allow-partial-rename)
2530
+
2531
+ Global:
2532
+ -i, --index <file> index to query (or set SACKVILLE_INDEX)
2533
+ `;
2534
+ async function run(argv, io) {
2535
+ const [command, ...rest] = argv;
2536
+ switch (command) {
2537
+ case "search": return cmdSearch(rest, io);
2538
+ case "get": return cmdGet(rest, io);
2539
+ case "versions": return cmdVersions(rest, io);
2540
+ case "detect": return cmdDetect(rest, io);
2541
+ case "api": return runApi(rest, io);
2542
+ case "browser": return runBrowser(rest, io);
2543
+ case "mutate": return runMutate(rest, io);
2544
+ case "coverage": return runCoverage(rest, io);
2545
+ case "flake": return runFlake(rest, io);
2546
+ case "deps": return runDeps(rest, io);
2547
+ case "lsp": return runLsp(rest, io);
2548
+ case "verify": return runVerify(rest, io);
2549
+ case "help":
2550
+ case "--help":
2551
+ case "-h":
2552
+ io.out(HELP);
2553
+ return 0;
2554
+ case void 0:
2555
+ io.err(HELP);
2556
+ return 1;
2557
+ default:
2558
+ io.err(`unknown command: ${command}\n`);
2559
+ io.err(HELP);
2560
+ return 1;
2561
+ }
2562
+ }
2563
+ function openIndex(indexFlag, io) {
2564
+ const path = indexFlag ?? io.env?.SACKVILLE_INDEX;
2565
+ if (!path) {
2566
+ io.err("no index given: pass --index <file> or set SACKVILLE_INDEX\n");
2567
+ return null;
2568
+ }
2569
+ return openDb(path);
2570
+ }
2571
+ async function cmdSearch(args, io) {
2572
+ const { values, positionals } = parseArgs({
2573
+ args,
2574
+ allowPositionals: true,
2575
+ options: {
2576
+ index: {
2577
+ type: "string",
2578
+ short: "i"
2579
+ },
2580
+ library: {
2581
+ type: "string",
2582
+ short: "l"
2583
+ },
2584
+ version: { type: "string" },
2585
+ installed: { type: "string" },
2586
+ project: {
2587
+ type: "string",
2588
+ short: "p"
2589
+ },
2590
+ ecosystem: { type: "string" },
2591
+ type: { type: "string" },
2592
+ limit: { type: "string" },
2593
+ json: { type: "boolean" }
2594
+ }
2595
+ });
2596
+ const query = positionals.join(" ").trim();
2597
+ if (!query) {
2598
+ io.err("search needs a query\n");
2599
+ return 1;
2600
+ }
2601
+ const db = openIndex(values.index, io);
2602
+ if (!db) return 1;
2603
+ try {
2604
+ let effectiveVersion = values.version;
2605
+ let note;
2606
+ if (!values.version && (values.installed || values.project) && values.library) {
2607
+ let requested = values.installed;
2608
+ if (!requested && values.project) {
2609
+ requested = detectInstalledVersion(values.project, values.library, { ecosystem: values.ecosystem }).version ?? void 0;
2610
+ if (!requested) note = `could not detect ${values.library} in ${values.project}`;
2611
+ }
2612
+ if (requested) {
2613
+ const res = resolveVersion(listVersions(db, values.library), requested);
2614
+ note = res.note;
2615
+ if (res.resolved) effectiveVersion = res.resolved;
2616
+ }
2617
+ }
2618
+ let queryVector;
2619
+ if (io.embedder) try {
2620
+ queryVector = await io.embedder.embed(query);
2621
+ } catch {
2622
+ queryVector = void 0;
2623
+ }
2624
+ const results = searchDocs(db, query, {
2625
+ library: values.library,
2626
+ version: effectiveVersion,
2627
+ type: values.type,
2628
+ limit: values.limit ? Number(values.limit) : void 0,
2629
+ queryVector
2630
+ });
2631
+ if (values.json) {
2632
+ io.out(`${JSON.stringify({
2633
+ query,
2634
+ version: effectiveVersion ?? null,
2635
+ note,
2636
+ results
2637
+ }, null, 2)}\n`);
2638
+ return 0;
2639
+ }
2640
+ if (note) io.err(`${note}\n`);
2641
+ if (results.length === 0) {
2642
+ io.out("no matches\n");
2643
+ return 0;
2644
+ }
2645
+ for (const r of results) {
2646
+ const sym = r.symbol ? ` (${r.symbol})` : "";
2647
+ io.out(`${r.version} [${r.type ?? "-"}] ${r.title}${sym}\n`);
2648
+ io.out(` ${r.snippet}\n`);
2649
+ io.out(` sackville://doc/${r.id}\n`);
2650
+ }
2651
+ return 0;
2652
+ } finally {
2653
+ db.close();
2654
+ }
2655
+ }
2656
+ function cmdGet(args, io) {
2657
+ const { values, positionals } = parseArgs({
2658
+ args,
2659
+ allowPositionals: true,
2660
+ options: {
2661
+ index: {
2662
+ type: "string",
2663
+ short: "i"
2664
+ },
2665
+ json: { type: "boolean" }
2666
+ }
2667
+ });
2668
+ const id = Number(positionals[0]);
2669
+ if (!Number.isInteger(id)) {
2670
+ io.err("get needs a numeric id\n");
2671
+ return 1;
2672
+ }
2673
+ const db = openIndex(values.index, io);
2674
+ if (!db) return 1;
2675
+ try {
2676
+ const doc = getDoc(db, id);
2677
+ if (!doc) {
2678
+ io.err(`no document with id ${id}\n`);
2679
+ return 1;
2680
+ }
2681
+ if (values.json) {
2682
+ io.out(`${JSON.stringify(doc, null, 2)}\n`);
2683
+ return 0;
2684
+ }
2685
+ io.out(`${doc.title} [${doc.type ?? "-"}] ${doc.library} ${doc.version}\n`);
2686
+ if (doc.headingPath) io.out(`${doc.headingPath}\n`);
2687
+ if (doc.url) io.out(`${doc.url}\n`);
2688
+ io.out(`\n${doc.body}\n`);
2689
+ if (doc.attribution) io.out(`\n— ${doc.attribution}\n`);
2690
+ return 0;
2691
+ } finally {
2692
+ db.close();
2693
+ }
2694
+ }
2695
+ function cmdVersions(args, io) {
2696
+ const { values, positionals } = parseArgs({
2697
+ args,
2698
+ allowPositionals: true,
2699
+ options: { index: {
2700
+ type: "string",
2701
+ short: "i"
2702
+ } }
2703
+ });
2704
+ const library = positionals[0];
2705
+ if (!library) {
2706
+ io.err("versions needs a library\n");
2707
+ return 1;
2708
+ }
2709
+ const db = openIndex(values.index, io);
2710
+ if (!db) return 1;
2711
+ try {
2712
+ const versions = listVersions(db, library);
2713
+ io.out(versions.length ? `${versions.join("\n")}\n` : `no versions indexed for ${library}\n`);
2714
+ return 0;
2715
+ } finally {
2716
+ db.close();
2717
+ }
2718
+ }
2719
+ function cmdDetect(args, io) {
2720
+ const { values, positionals } = parseArgs({
2721
+ args,
2722
+ allowPositionals: true,
2723
+ options: {
2724
+ index: {
2725
+ type: "string",
2726
+ short: "i"
2727
+ },
2728
+ ecosystem: { type: "string" }
2729
+ }
2730
+ });
2731
+ const [project, library] = positionals;
2732
+ if (!project || !library) {
2733
+ io.err("detect needs <project> <library>\n");
2734
+ return 1;
2735
+ }
2736
+ const db = openIndex(values.index, io);
2737
+ if (!db) return 1;
2738
+ try {
2739
+ const detected = detectInstalledVersion(project, library, { ecosystem: values.ecosystem });
2740
+ const res = resolveVersion(listVersions(db, library), detected.version ?? "");
2741
+ io.out(`detected: ${detected.version ?? "(none)"} (${detected.source})\n`);
2742
+ io.out(`resolved: ${res.resolved ?? "(none)"}\n`);
2743
+ io.out(`${res.note}\n`);
2744
+ return 0;
2745
+ } finally {
2746
+ db.close();
2747
+ }
2748
+ }
2749
+ //#endregion
2750
+ export { run as t };
2751
+
2752
+ //# sourceMappingURL=src-DM4aqvlX.mjs.map