@synovitec/trellis 0.0.0-stage → 0.6.0-rc.2

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +393 -0
  2. package/LICENSE +190 -0
  3. package/README.md +206 -2
  4. package/SECURITY.md +104 -0
  5. package/bin/trellis.mjs +5 -0
  6. package/docs/attestation/v0.1.md +96 -0
  7. package/package.json +78 -4
  8. package/schema/trellis.config.schema.json +349 -0
  9. package/src/agents/adapters.mjs +155 -0
  10. package/src/agents/git.mjs +241 -0
  11. package/src/agents/glob.mjs +41 -0
  12. package/src/agents/hooks.mjs +780 -0
  13. package/src/agents/install-adapters.mjs +172 -0
  14. package/src/agents/install.mjs +302 -0
  15. package/src/agents/manifests.mjs +127 -0
  16. package/src/agents/rules.mjs +330 -0
  17. package/src/agents/shell.mjs +253 -0
  18. package/src/agents/state.mjs +233 -0
  19. package/src/agents/transcript.mjs +120 -0
  20. package/src/cli/main.mjs +191 -0
  21. package/src/commands/attest.mjs +207 -0
  22. package/src/commands/checkpoint-state.mjs +62 -0
  23. package/src/commands/doctor.mjs +98 -0
  24. package/src/commands/guide.mjs +62 -0
  25. package/src/commands/init.mjs +129 -0
  26. package/src/commands/key.mjs +31 -0
  27. package/src/commands/scan.mjs +51 -0
  28. package/src/commands/uninstall.mjs +36 -0
  29. package/src/commands/verify.mjs +509 -0
  30. package/src/core/abatty.mjs +25 -0
  31. package/src/core/agents-block.mjs +65 -0
  32. package/src/core/attestation.mjs +213 -0
  33. package/src/core/config.mjs +135 -0
  34. package/src/core/context.mjs +280 -0
  35. package/src/core/diff.mjs +189 -0
  36. package/src/core/evidence.mjs +167 -0
  37. package/src/core/mcp.mjs +51 -0
  38. package/src/index.mjs +11 -0
  39. package/src/mcp/client.mjs +164 -0
  40. package/src/mcp/launch.mjs +57 -0
  41. package/src/mcp/relay.mjs +111 -0
  42. package/src/mcp/server.mjs +210 -0
  43. package/src/mcp/trust.mjs +142 -0
  44. package/src/tools/registry.mjs +370 -0
  45. package/src/tools/roles.mjs +17 -0
  46. package/src/verify/deps.mjs +382 -0
  47. package/src/verify/gitleaks-default.toml +3211 -0
  48. package/src/verify/rules.mjs +163 -0
  49. package/src/verify/secrets.mjs +169 -0
  50. package/src/verify/test-commands.mjs +116 -0
@@ -0,0 +1,382 @@
1
+ // The dependencies a change adds or moves, checked at their exact version: the package exists on
2
+ // its registry (models invent package names), that version is published, and it has no known
3
+ // vulnerability (OSV). Exact versions come from what the change itself records: npm's lockfile,
4
+ // pip pins, go.mod. A dependency without one is reported as unpinned, never passed silently, and
5
+ // a registry or OSV that cannot be reached makes the check unavailable, never clean.
6
+ import { spawnSync } from "node:child_process";
7
+ import { addedDependencies } from "../agents/manifests.mjs";
8
+ import { GitError } from "../core/diff.mjs";
9
+
10
+ /** @typedef {{ ecosystem: string, name: string, version: string | null, source?: string | null, integrity?: string | null }} Dependency source: where it comes from when not its registry; integrity: the lockfile's hash of it */
11
+ /** @typedef {{ ecosystem: string, name: string, version: string | null, problem: string }} DependencyFinding */
12
+ /**
13
+ * @typedef {object} DependencyCheck
14
+ * @property {"clean" | "findings" | "unavailable"} status
15
+ * @property {DependencyFinding[]} findings
16
+ * @property {string[]} unpinned exist, but with no exact version their vulnerabilities are unknown
17
+ * @property {string[]} unchecked what no registry vouched for: a package from git, a URL or another
18
+ * registry, or one of an ecosystem Trellis does not query yet
19
+ * @property {string} [detail]
20
+ */
21
+
22
+ /** A blob's text, or null for no blob. @param {string} root @param {string | null} blob */
23
+ function blobText(root, blob) {
24
+ if (!blob) return null;
25
+ const r = spawnSync("git", ["cat-file", "blob", blob], { cwd: root, encoding: "utf8", maxBuffer: 256 * 1024 * 1024, windowsHide: true });
26
+ if (r.status !== 0) throw new GitError(["cat-file", "blob", blob], (r.stderr || `exit ${r.status}`).trim());
27
+ return r.stdout;
28
+ }
29
+
30
+ /** @param {string} path */
31
+ const fileOf = (path) => path.slice(path.lastIndexOf("/") + 1);
32
+ /** @param {string} path */
33
+ const dirOf = (path) => (path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "");
34
+
35
+ const NPM_REGISTRY = "https://registry.npmjs.org/";
36
+
37
+ /**
38
+ * Where a lockfile entry was fetched from, when it is not this package's own tarball on the npm
39
+ * registry: git, a URL, another registry, or another package's tarball on npm (an entry named
40
+ * lodash that downloads something else).
41
+ * @param {unknown} resolved @param {string} name @param {string} version
42
+ */
43
+ const sourceOf = (resolved, name, version) => {
44
+ if (typeof resolved !== "string") return null;
45
+ if (resolved.startsWith(NPM_REGISTRY)) return resolved === `${NPM_REGISTRY}${name}/-/${name.split("/").pop()}-${version}.tgz` ? null : resolved;
46
+ return resolved.match(/^[^:]+:\/\/[^/#]+/)?.[0] ?? resolved.split(/[#?]/)[0];
47
+ };
48
+
49
+ /**
50
+ * One lockfile entry as the dependency it installs. An alias (`"lodash": "npm:other@1.0.0"`)
51
+ * installs `other`, by `name` (v2/v3) or by its version (v1).
52
+ * @param {string} folderName @param {any} entry
53
+ * @returns {Dependency | null}
54
+ */
55
+ function entryOf(folderName, entry) {
56
+ if (entry?.link) return null;
57
+ // No version, but a place it is fetched from: what is installed is whatever that URL serves.
58
+ if (typeof entry?.version !== "string") return typeof entry?.resolved === "string" ? { ecosystem: "npm", name: typeof entry.name === "string" ? entry.name : folderName, version: null, source: entry.resolved } : null;
59
+ let name = typeof entry.name === "string" ? entry.name : folderName;
60
+ let version = entry.version;
61
+ const alias = version.match(/^npm:((?:@[^/]+\/)?[^@]+)@(.+)$/);
62
+ if (alias) [, name, version] = alias;
63
+ return { ecosystem: "npm", name, version, source: sourceOf(entry.resolved, name, version), integrity: typeof entry.integrity === "string" ? entry.integrity : null };
64
+ }
65
+
66
+ /**
67
+ * Every installed package of an npm lockfile, by its place in it: v2/v3 `packages`, and v1's
68
+ * nested `dependencies`.
69
+ * @param {string | null} text
70
+ * @returns {Map<string, Dependency>}
71
+ */
72
+ function lockEntries(text) {
73
+ /** @type {Map<string, Dependency>} */
74
+ const out = new Map();
75
+ if (!text) return out;
76
+ let lock;
77
+ try {
78
+ lock = JSON.parse(text.replace(/^\uFEFF/, ""));
79
+ } catch {
80
+ return out;
81
+ }
82
+ for (const [key, entry] of Object.entries(lock?.packages ?? {})) {
83
+ const i = key.lastIndexOf("node_modules/");
84
+ const d = i === -1 ? null : entryOf(key.slice(i + "node_modules/".length), entry);
85
+ if (d) out.set(key, d);
86
+ }
87
+ if (!lock?.packages) {
88
+ const walk = (/** @type {any} */ deps, /** @type {string} */ at) => {
89
+ for (const [name, entry] of Object.entries(deps ?? {})) {
90
+ const key = `${at}node_modules/${name}`;
91
+ const d = entryOf(name, entry);
92
+ if (d) out.set(key, d);
93
+ walk(/** @type {any} */ (entry)?.dependencies, `${key}/`);
94
+ }
95
+ };
96
+ walk(lock?.dependencies, "");
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /** @param {Dependency} d */
102
+ const idOf = (d) => `${d.ecosystem}:${d.name}@${d.version}${d.source ? ` ${d.source}` : ""}${d.integrity ? ` ${d.integrity}` : ""}`;
103
+
104
+ /** Whether a JSON file parses, past a byte-order mark. @param {string | null} text */
105
+ const parses = (text) => {
106
+ try {
107
+ JSON.parse((text ?? "").replace(/^\uFEFF/, ""));
108
+ return true;
109
+ } catch {
110
+ return false;
111
+ }
112
+ };
113
+
114
+ /** The dependency specs of a package.json, by name, across its dependency fields. @param {string | null} text */
115
+ function npmSpecs(text) {
116
+ /** @type {Map<string, string>} */
117
+ const out = new Map();
118
+ let doc;
119
+ try {
120
+ doc = JSON.parse((text ?? "null").replace(/^\uFEFF/, ""));
121
+ } catch {
122
+ return out;
123
+ }
124
+ for (const field of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
125
+ for (const [name, spec] of Object.entries(doc?.[field] ?? {})) if (typeof spec === "string") out.set(name, spec);
126
+ }
127
+ return out;
128
+ }
129
+
130
+ /** A spec that does not name a version on the npm registry: git, a URL, a file, an alias. @param {string} spec */
131
+ const nonRegistrySpec = (spec) => (/^(git\+|git:|github:|gitlab:|bitbucket:|https?:|file:|link:|npm:)|^[\w.-]+\/[\w.-]+(#.*)?$/.test(spec) ? spec : null);
132
+
133
+ /**
134
+ * What a requirements file installs: `name==version` pins and bare names, and what PyPI does not
135
+ * vouch for: another index, a VCS or URL requirement, `name @ url`.
136
+ * @param {string | null} text
137
+ * @returns {Map<string, { version: string | null, source: string | null }>}
138
+ */
139
+ function pins(text) {
140
+ /** @type {Map<string, { version: string | null, source: string | null }>} */
141
+ const out = new Map();
142
+ for (const raw of (text ?? "").split(/\r?\n/)) {
143
+ const line = raw.replace(/(^|\s)#.*/, "").trim();
144
+ if (!line) continue;
145
+ const index = line.match(/^(--index-url|--extra-index-url|-i|--find-links|-f)[\s=]+(\S+)/);
146
+ if (index) {
147
+ out.set(index[1], { version: null, source: index[2] });
148
+ continue;
149
+ }
150
+ const vcs = line.match(/^(?:-e\s+|--editable[\s=]+)?((?:git\+|hg\+|svn\+|bzr\+|https?:|file:)\S+)/);
151
+ if (vcs) {
152
+ out.set(vcs[1].match(/#egg=([\w.-]+)/)?.[1]?.toLowerCase() ?? vcs[1], { version: null, source: vcs[1] });
153
+ continue;
154
+ }
155
+ // Another file's requirements: read only when it is itself named requirements*.txt.
156
+ const include = line.match(/^(-r|-c|--requirement|--constraint)[\s=]+(\S+)/);
157
+ if (include) {
158
+ out.set(`${include[1]} ${include[2]}`, { version: null, source: "an included requirements file" });
159
+ continue;
160
+ }
161
+ if (line.startsWith("-")) continue;
162
+ const name = line.split(/[\s\[<>=!~;@]/)[0].toLowerCase();
163
+ const url = line.match(/^[\w.\-\[\], ]+@\s*(\S+)/)?.[1] ?? null;
164
+ const exact = line.match(/==\s*([^\s;,]+)/);
165
+ if (name) out.set(name, { version: exact ? exact[1] : null, source: url });
166
+ }
167
+ return out;
168
+ }
169
+
170
+ /** `replace` directives of a go.mod: the module, and what it is replaced with. @param {string | null} text */
171
+ function goReplaces(text) {
172
+ /** @type {Map<string, string>} */
173
+ const out = new Map();
174
+ let block = false;
175
+ for (const raw of (text ?? "").split(/\r?\n/)) {
176
+ const line = raw.replace(/\/\/.*/, "").trim();
177
+ if (/^replace\s*\($/.test(line)) block = true;
178
+ else if (block && line === ")") block = false;
179
+ else {
180
+ const m = (block ? line : line.replace(/^replace\s+/, "")).match(/^(\S+)(?:\s+\S+)?\s+=>\s+(.+)$/);
181
+ if (m && (block || /^replace\s/.test(line))) out.set(m[1], m[2].trim());
182
+ }
183
+ }
184
+ return out;
185
+ }
186
+
187
+ /** Settings that change where packages come from: a registry, a proxy, a scope's registry. */
188
+ const REGISTRY_SETTINGS = new Set([".npmrc", ".yarnrc", ".yarnrc.yml", "pip.conf", "pip.ini", ".pypirc"]);
189
+
190
+ /** What overrides the versions a package.json's dependencies resolve to. @param {string | null} text */
191
+ const overridesOf = (text) => {
192
+ let doc;
193
+ try {
194
+ doc = JSON.parse((text ?? "null").replace(/^\uFEFF/, ""));
195
+ } catch {
196
+ return "";
197
+ }
198
+ return JSON.stringify([doc?.overrides ?? null, doc?.resolutions ?? null, doc?.pnpm?.overrides ?? null]);
199
+ };
200
+
201
+ /** Manifests Trellis does not read yet: a change to one is named, never passed silently. */
202
+ const UNREAD_MANIFESTS = /^(setup\.py|setup\.cfg|Pipfile|environment\.ya?ml|conda\.ya?ml|pom\.xml|build\.gradle(\.kts)?|settings\.gradle(\.kts)?|gradle\.properties|.+\.csproj|.+\.fsproj|.+\.vbproj|packages\.config|Directory\.Packages\.props|pubspec\.ya?ml|deno\.jsonc?|import_map\.json|mix\.exs|Package\.swift|Podfile|build\.sbt|project\.clj|deps\.edn|.+\.cabal|stack\.yaml|rebar\.config|vcpkg\.json|conanfile\.(txt|py))$/;
203
+
204
+ /** Lockfiles Trellis does not read yet: a change to one is named, never passed silently. */
205
+ const UNREAD_LOCKS = new Set(["pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb", "npm-shrinkwrap.json", "poetry.lock", "uv.lock", "Pipfile.lock", "Cargo.lock", "Gemfile.lock", "composer.lock", "go.sum"]);
206
+
207
+ /** `module version` requirements of a go.mod. @param {string | null} text */
208
+ function goRequires(text) {
209
+ /** @type {Map<string, string>} */
210
+ const out = new Map();
211
+ let block = false;
212
+ for (const raw of (text ?? "").split(/\r?\n/)) {
213
+ const line = raw.replace(/\/\/.*/, "").trim();
214
+ if (/^require\s*\($/.test(line)) block = true;
215
+ else if (block && line === ")") block = false;
216
+ else {
217
+ const m = (block ? line : line.replace(/^require\s+/, "")).match(/^(\S+)\s+(v\S+)$/);
218
+ if (m && (block || /^require\s/.test(line))) out.set(m[1], m[2]);
219
+ }
220
+ }
221
+ return out;
222
+ }
223
+
224
+ const OTHER_ECOSYSTEMS = { "pyproject.toml": "PyPI", "Cargo.toml": "crates.io", "Gemfile": "RubyGems", "composer.json": "Packagist" };
225
+
226
+ /**
227
+ * The dependencies a change adds, or moves to another version, read from the change's own blobs.
228
+ * @param {string} root
229
+ * @param {import("../core/diff.mjs").Change[]} changes
230
+ * @returns {Dependency[]}
231
+ */
232
+ export function dependencyChanges(root, changes) {
233
+ /** @type {Map<string, Dependency>} */
234
+ const found = new Map();
235
+ const add = (/** @type {Dependency} */ d) => found.set(idOf(d), d);
236
+ // The lockfiles the change writes, by folder: what they install covers the manifest beside them.
237
+ const locks = new Map(changes.filter((c) => c.status !== "D" && fileOf(c.path) === "package-lock.json").map((c) => [dirOf(c.path), lockEntries(blobText(root, c.newBlob))]));
238
+ for (const c of changes) {
239
+ if (c.status === "D") continue;
240
+ const file = fileOf(c.path);
241
+ // A manifest or lockfile that does not parse holds dependencies Trellis cannot see: the check
242
+ // is unavailable, never clean.
243
+ if ((file === "package-lock.json" || file === "package.json") && !parses(blobText(root, c.newBlob))) {
244
+ add({ ecosystem: "unreadable", name: c.path, version: null, source: "does not parse as JSON" });
245
+ continue;
246
+ }
247
+ if (file === "package-lock.json") {
248
+ const before = new Set([...lockEntries(blobText(root, c.oldBlob)).values()].map(idOf));
249
+ for (const d of /** @type {Map<string, Dependency>} */ (locks.get(dirOf(c.path))).values()) if (!before.has(idOf(d))) add(d);
250
+ } else if (file === "package.json") {
251
+ // Each spec added or changed. One that names no registry version (git, a URL, an alias) is
252
+ // named; a name the lockfile beside it installs is checked from there, at its exact
253
+ // version; any other is checked here, by name.
254
+ const lock = locks.get(dirOf(c.path));
255
+ const before = npmSpecs(blobText(root, c.oldBlob));
256
+ if (overridesOf(blobText(root, c.oldBlob)) !== overridesOf(blobText(root, c.newBlob))) add({ ecosystem: "npm", name: `${c.path} overrides`, version: null, source: "overrides or resolutions Trellis does not resolve" });
257
+ for (const [name, spec] of npmSpecs(blobText(root, c.newBlob))) {
258
+ if (before.get(name) === spec) continue;
259
+ const elsewhere = nonRegistrySpec(spec);
260
+ if (elsewhere) add({ ecosystem: "npm", name, version: null, source: elsewhere });
261
+ else if (!lock?.has(`node_modules/${name}`)) add({ ecosystem: "npm", name, version: null });
262
+ }
263
+ } else if (/^requirements.*\.txt$/.test(file) || /(^|\/)requirements\/[^/]+\.txt$/.test(c.path)) {
264
+ const before = pins(blobText(root, c.oldBlob));
265
+ for (const [name, pin] of pins(blobText(root, c.newBlob))) {
266
+ const had = before.get(name);
267
+ if (had && had.version === pin.version && had.source === pin.source) continue;
268
+ add({ ecosystem: "PyPI", name, version: pin.version, source: pin.source });
269
+ }
270
+ } else if (file === "go.mod") {
271
+ const before = goRequires(blobText(root, c.oldBlob));
272
+ for (const [name, version] of goRequires(blobText(root, c.newBlob))) if (before.get(name) !== version) add({ ecosystem: "Go", name, version });
273
+ const replacedBefore = goReplaces(blobText(root, c.oldBlob));
274
+ for (const [name, by] of goReplaces(blobText(root, c.newBlob))) if (replacedBefore.get(name) !== by) add({ ecosystem: "Go", name, version: null, source: `replace => ${by}` });
275
+ } else if (UNREAD_MANIFESTS.test(file)) {
276
+ add({ ecosystem: "manifest", name: c.path, version: null, source: "a manifest Trellis does not read yet" });
277
+ } else if (REGISTRY_SETTINGS.has(file)) {
278
+ add({ ecosystem: "settings", name: c.path, version: null, source: "registry settings: where packages come from" });
279
+ } else if (UNREAD_LOCKS.has(file)) {
280
+ add({ ecosystem: "lockfile", name: c.path, version: null, source: "a lockfile Trellis does not read yet" });
281
+ } else if (file in OTHER_ECOSYSTEMS) {
282
+ const ecosystem = OTHER_ECOSYSTEMS[/** @type {keyof typeof OTHER_ECOSYSTEMS} */ (file)];
283
+ for (const name of addedDependencies(c.path, blobText(root, c.oldBlob), blobText(root, c.newBlob))) add({ ecosystem, name, version: null });
284
+ }
285
+ }
286
+ return [...found.values()];
287
+ }
288
+
289
+ /** A Go module path as the module proxy wants it: each capital as `!` and its lower case. @param {string} path */
290
+ export const goEscape = (path) => path.replace(/[A-Z]/g, (c) => `!${c.toLowerCase()}`);
291
+
292
+ /** @typedef {(url: string, init?: RequestInit) => Promise<{ status: number, json: () => Promise<any> }>} Http */
293
+
294
+ /** @type {Http} */
295
+ const defaultHttp = (url, init) => fetch(url, { ...init, signal: AbortSignal.timeout(15_000) });
296
+
297
+ /** Thrown when a registry or OSV did not answer: the check is unavailable, not passed. */
298
+ class Unreachable extends Error {}
299
+
300
+ /** @param {Http} http @param {string} url @param {RequestInit} [init] */
301
+ async function get(http, url, init) {
302
+ let r;
303
+ try {
304
+ r = await http(url, init);
305
+ } catch (e) {
306
+ throw new Unreachable(`${new URL(url).host} could not be reached (${/** @type {Error} */ (e).message})`);
307
+ }
308
+ if (r.status === 404 || r.status === 410) return null;
309
+ if (r.status !== 200) throw new Unreachable(`${new URL(url).host} answered ${r.status}`);
310
+ return r.json();
311
+ }
312
+
313
+ /**
314
+ * Whether the package exists and the version is published, for the registries Trellis reads.
315
+ * @param {Http} http
316
+ * @param {Dependency} d
317
+ * @returns {Promise<string | null | undefined>} the problem, null when fine, undefined when not checked
318
+ */
319
+ async function registryProblem(http, d) {
320
+ if (d.ecosystem === "npm") {
321
+ const doc = await get(http, `https://registry.npmjs.org/${encodeURIComponent(d.name).replace(/^%40/, "@")}`, { headers: { accept: "application/vnd.npm.install-v1+json" } });
322
+ if (!doc) return "does not exist on npm";
323
+ if (d.version && !(d.version in (doc.versions ?? {}))) return "version not published";
324
+ // The lockfile's hash must be npm's own for that version, or the tarball is not npm's package.
325
+ const published = d.version ? doc.versions?.[d.version]?.dist?.integrity : null;
326
+ return d.integrity && published && d.integrity !== published ? "its lockfile integrity is not the registry's: the tarball installed is not this package" : null;
327
+ }
328
+ if (d.ecosystem === "PyPI") {
329
+ const doc = await get(http, `https://pypi.org/pypi/${encodeURIComponent(d.name)}/json`);
330
+ if (!doc) return "does not exist on PyPI";
331
+ return d.version && !(doc.releases?.[d.version]?.length) ? "version not published" : null;
332
+ }
333
+ if (d.ecosystem === "Go" && d.version) {
334
+ const info = await get(http, `https://proxy.golang.org/${goEscape(d.name)}/@v/${encodeURIComponent(d.version)}.info`);
335
+ return info ? null : "module version not found on the Go module proxy";
336
+ }
337
+ return undefined;
338
+ }
339
+
340
+ /**
341
+ * Checks dependencies: existence and published version on their registry, and known
342
+ * vulnerabilities of exact versions in one OSV batch.
343
+ * @param {Dependency[]} deps
344
+ * @param {{ http?: Http }} [opts]
345
+ * @returns {Promise<DependencyCheck>}
346
+ */
347
+ export async function checkDependencies(deps, opts = {}) {
348
+ const http = opts.http ?? defaultHttp;
349
+ /** @type {DependencyFinding[]} */
350
+ const findings = [];
351
+ /** @type {Set<string>} */
352
+ const unchecked = new Set();
353
+ const unreadable = deps.filter((d) => d.ecosystem === "unreadable");
354
+ if (unreadable.length) return { status: "unavailable", findings: [], unpinned: [], unchecked: [], detail: `${unreadable.map((d) => d.name).join(", ")} could not be read (not valid JSON), so the dependencies it holds were not checked` };
355
+ // A package fetched from elsewhere is not the registry's package of that name: neither is looked up.
356
+ for (const d of deps) if (d.source) unchecked.add(`${d.name} (from ${d.source})`);
357
+ const fromRegistry = deps.filter((d) => !d.source);
358
+ try {
359
+ const problems = await Promise.all(fromRegistry.map((d) => registryProblem(http, d)));
360
+ problems.forEach((p, i) => {
361
+ if (p === undefined) unchecked.add(`${fromRegistry[i].name} (${fromRegistry[i].ecosystem})`);
362
+ if (p) findings.push({ ...fromRegistry[i], problem: p });
363
+ });
364
+ const missing = new Set(findings.map((f) => `${f.ecosystem}:${f.name}`));
365
+ const exact = fromRegistry.filter((d) => d.version && !missing.has(`${d.ecosystem}:${d.name}`));
366
+ if (exact.length) {
367
+ const queries = exact.map((d) => ({ package: { name: d.name, ecosystem: d.ecosystem }, version: d.version }));
368
+ const res = await get(http, "https://api.osv.dev/v1/querybatch", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ queries }) });
369
+ if (!res || !Array.isArray(res.results) || res.results.length !== exact.length) throw new Unreachable("OSV's answer could not be read");
370
+ res.results.forEach((/** @type {any} */ r, /** @type {number} */ i) => {
371
+ const ids = (r?.vulns ?? []).map((/** @type {any} */ v) => v.id);
372
+ if (ids.length) findings.push({ ...exact[i], problem: `vulnerable (${ids.join(", ")})` });
373
+ });
374
+ }
375
+ } catch (e) {
376
+ if (e instanceof Unreachable) return { status: "unavailable", findings, unpinned: [], unchecked: [...unchecked], detail: e.message };
377
+ throw e;
378
+ }
379
+ // What comes from elsewhere is named under unchecked, not as merely unpinned.
380
+ const unpinned = deps.filter((d) => d.version == null && !d.source && !findings.some((f) => f.name === d.name)).map((d) => d.name);
381
+ return { status: findings.length ? "findings" : "clean", findings, unpinned, unchecked: [...unchecked] };
382
+ }