@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.
- package/LICENSE +201 -0
- package/README.md +288 -0
- package/dist/bin.d.mts +1 -0
- package/dist/bin.mjs +15 -0
- package/dist/bin.mjs.map +1 -0
- package/dist/index.d.mts +15 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +2 -0
- package/dist/src-DM4aqvlX.mjs +2752 -0
- package/dist/src-DM4aqvlX.mjs.map +1 -0
- package/package.json +53 -0
|
@@ -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
|