@4da/mcp-server 5.0.3 → 6.0.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/CHANGELOG.md +667 -0
- package/README.md +66 -35
- package/dist/build-staleness.d.ts +35 -0
- package/dist/build-staleness.js +95 -0
- package/dist/db.d.ts +53 -2
- package/dist/db.js +242 -36
- package/dist/doctor.js +27 -7
- package/dist/embeddings.js +7 -1
- package/dist/freshness-markers.d.ts +34 -0
- package/dist/freshness-markers.js +74 -0
- package/dist/gitignore.d.ts +37 -0
- package/dist/gitignore.js +130 -0
- package/dist/index.js +127 -44
- package/dist/live/bun-lockfile.d.ts +26 -0
- package/dist/live/bun-lockfile.js +123 -0
- package/dist/live/cargo-platform.d.ts +38 -0
- package/dist/live/cargo-platform.js +178 -0
- package/dist/live/changelog-classify.d.ts +41 -0
- package/dist/live/changelog-classify.js +207 -0
- package/dist/live/changelog.d.ts +64 -0
- package/dist/live/changelog.js +318 -0
- package/dist/live/crates-registry.js +3 -1
- package/dist/live/crates-versions.d.ts +47 -0
- package/dist/live/crates-versions.js +201 -0
- package/dist/live/dependency-set.d.ts +13 -0
- package/dist/live/dependency-set.js +95 -0
- package/dist/live/dev-scope.d.ts +32 -0
- package/dist/live/dev-scope.js +102 -0
- package/dist/live/file-signature.d.ts +18 -0
- package/dist/live/file-signature.js +43 -0
- package/dist/live/go-registry.js +3 -1
- package/dist/live/hn-fetcher.js +9 -3
- package/dist/live/index.d.ts +105 -2
- package/dist/live/index.js +266 -139
- package/dist/live/install-state.d.ts +43 -0
- package/dist/live/install-state.js +150 -0
- package/dist/live/js-lockfile-readers.d.ts +46 -0
- package/dist/live/js-lockfile-readers.js +181 -0
- package/dist/live/lockfile-parsers-pygo.d.ts +60 -0
- package/dist/live/lockfile-parsers-pygo.js +274 -0
- package/dist/live/lockfile-parsers.d.ts +69 -0
- package/dist/live/lockfile-parsers.js +311 -0
- package/dist/live/lockfile-types.d.ts +34 -0
- package/dist/live/lockfile-types.js +42 -0
- package/dist/live/maintenance.d.ts +33 -0
- package/dist/live/maintenance.js +37 -0
- package/dist/live/npm-packument.d.ts +47 -0
- package/dist/live/npm-packument.js +150 -0
- package/dist/live/npm-registry.js +67 -53
- package/dist/live/osv-scanner.d.ts +31 -1
- package/dist/live/osv-scanner.js +119 -10
- package/dist/live/package-archive.d.ts +49 -0
- package/dist/live/package-archive.js +186 -0
- package/dist/live/pypi-registry.js +3 -1
- package/dist/live/registry-health.d.ts +16 -0
- package/dist/live/registry-health.js +74 -0
- package/dist/live/release-metadata.d.ts +67 -0
- package/dist/live/release-metadata.js +16 -0
- package/dist/live/resolution.d.ts +43 -0
- package/dist/live/resolution.js +62 -0
- package/dist/live/semver-precedence.d.ts +30 -0
- package/dist/live/semver-precedence.js +83 -0
- package/dist/live/severity-scope.d.ts +57 -0
- package/dist/live/severity-scope.js +89 -0
- package/dist/live/types.d.ts +83 -0
- package/dist/live/upgrade-sources.d.ts +71 -0
- package/dist/live/upgrade-sources.js +198 -0
- package/dist/live/version-compare.d.ts +39 -0
- package/dist/live/version-compare.js +130 -0
- package/dist/live/version-resolver.d.ts +14 -1
- package/dist/live/version-resolver.js +93 -303
- package/dist/native-bindings.d.ts +19 -0
- package/dist/native-bindings.js +39 -0
- package/dist/project-manifests.d.ts +40 -0
- package/dist/project-manifests.js +193 -0
- package/dist/project-scanner.js +38 -48
- package/dist/project-tree.d.ts +41 -0
- package/dist/project-tree.js +110 -0
- package/dist/prompts.d.ts +28 -0
- package/dist/prompts.js +54 -0
- package/dist/schema-registry.d.ts +41 -29
- package/dist/schema-registry.js +98 -105
- package/dist/server-instructions.d.ts +11 -0
- package/dist/server-instructions.js +36 -0
- package/dist/tool-args.d.ts +28 -0
- package/dist/tool-args.js +93 -0
- package/dist/tool-dispatch.d.ts +1 -1
- package/dist/tool-dispatch.js +42 -8
- package/dist/tools/app-plan.d.ts +92 -0
- package/dist/tools/app-plan.js +155 -0
- package/dist/tools/briefing-stored-advisories.d.ts +20 -0
- package/dist/tools/briefing-stored-advisories.js +58 -0
- package/dist/tools/briefing-task-scope.d.ts +73 -0
- package/dist/tools/briefing-task-scope.js +197 -0
- package/dist/tools/briefing-wisdom.d.ts +31 -0
- package/dist/tools/briefing-wisdom.js +148 -0
- package/dist/tools/decision-enforcement.d.ts +0 -5
- package/dist/tools/decision-enforcement.js +0 -4
- package/dist/tools/decision-memory.js +18 -66
- package/dist/tools/dependency-check-signals.d.ts +74 -0
- package/dist/tools/dependency-check-signals.js +264 -0
- package/dist/tools/dependency-check.d.ts +98 -0
- package/dist/tools/dependency-check.js +295 -0
- package/dist/tools/dependency-health.js +74 -21
- package/dist/tools/developer-dna.d.ts +3 -0
- package/dist/tools/developer-dna.js +16 -15
- package/dist/tools/ecosystem-pulse.d.ts +6 -2
- package/dist/tools/ecosystem-pulse.js +53 -14
- package/dist/tools/get-actionable-signals.d.ts +26 -4
- package/dist/tools/get-actionable-signals.js +214 -251
- package/dist/tools/get-context.d.ts +2 -0
- package/dist/tools/get-context.js +9 -1
- package/dist/tools/get-relevant-content.d.ts +3 -1
- package/dist/tools/get-relevant-content.js +34 -3
- package/dist/tools/index.d.ts +4 -2
- package/dist/tools/index.js +4 -2
- package/dist/tools/install-drift-notes.d.ts +51 -0
- package/dist/tools/install-drift-notes.js +172 -0
- package/dist/tools/knowledge-gap-citations.d.ts +55 -0
- package/dist/tools/knowledge-gap-citations.js +132 -0
- package/dist/tools/knowledge-gap-exposure.d.ts +72 -0
- package/dist/tools/knowledge-gap-exposure.js +160 -0
- package/dist/tools/knowledge-gap-grading.d.ts +91 -0
- package/dist/tools/knowledge-gap-grading.js +157 -0
- package/dist/tools/knowledge-gap-installs.d.ts +65 -0
- package/dist/tools/knowledge-gap-installs.js +134 -0
- package/dist/tools/knowledge-gap-ranges.d.ts +51 -0
- package/dist/tools/knowledge-gap-ranges.js +146 -0
- package/dist/tools/knowledge-gap-scope.d.ts +63 -0
- package/dist/tools/knowledge-gap-scope.js +118 -0
- package/dist/tools/knowledge-gaps.d.ts +12 -3
- package/dist/tools/knowledge-gaps.js +172 -121
- package/dist/tools/package-presence.d.ts +24 -0
- package/dist/tools/package-presence.js +35 -0
- package/dist/tools/record-feedback.d.ts +0 -1
- package/dist/tools/record-feedback.js +7 -8
- package/dist/tools/signal-classifier.d.ts +25 -0
- package/dist/tools/signal-classifier.js +40 -0
- package/dist/tools/upgrade-impact-callsites.d.ts +52 -0
- package/dist/tools/upgrade-impact-callsites.js +226 -0
- package/dist/tools/upgrade-impact-literals.d.ts +45 -0
- package/dist/tools/upgrade-impact-literals.js +118 -0
- package/dist/tools/upgrade-impact-report.d.ts +80 -0
- package/dist/tools/upgrade-impact-report.js +178 -0
- package/dist/tools/upgrade-impact.d.ts +84 -0
- package/dist/tools/upgrade-impact.js +305 -0
- package/dist/tools/upgrade-planner.d.ts +41 -8
- package/dist/tools/upgrade-planner.js +201 -53
- package/dist/tools/vulnerability-scan-concise.d.ts +72 -0
- package/dist/tools/vulnerability-scan-concise.js +97 -0
- package/dist/tools/vulnerability-scan-format.d.ts +137 -0
- package/dist/tools/vulnerability-scan-format.js +276 -0
- package/dist/tools/vulnerability-scan.d.ts +338 -18
- package/dist/tools/vulnerability-scan.js +84 -125
- package/dist/tools/what-should-i-know.d.ts +86 -19
- package/dist/tools/what-should-i-know.js +277 -283
- package/dist/types.d.ts +4 -1
- package/package.json +27 -30
- package/dist/schemas/agent-memory.json +0 -49
- package/dist/schemas/decision-enforcement.json +0 -22
- package/dist/schemas/decision-memory.json +0 -54
- package/dist/schemas/dependency-health.json +0 -27
- package/dist/schemas/developer-dna.json +0 -219
- package/dist/schemas/ecosystem-pulse.json +0 -17
- package/dist/schemas/get-actionable-signals.json +0 -29
- package/dist/schemas/get-context.json +0 -17
- package/dist/schemas/get-relevant-content.json +0 -26
- package/dist/schemas/knowledge-gaps.json +0 -14
- package/dist/schemas/record-feedback.json +0 -24
- package/dist/schemas/upgrade-planner.json +0 -22
- package/dist/schemas/vulnerability-scan.json +0 -26
- package/dist/schemas/what-should-i-know.json +0 -19
package/dist/live/osv-scanner.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { fetchWithTimeout, fetchJson } from "./http-utils.js";
|
|
11
11
|
import { cvssBaseScore } from "./cvss.js";
|
|
12
|
+
import { compareVersions, isComparable, maxVersion, samePackageName } from "./version-compare.js";
|
|
12
13
|
const OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch";
|
|
13
14
|
const OSV_VULN_URL = "https://api.osv.dev/v1/vulns/";
|
|
14
15
|
const OSV_TIMEOUT_MS = 15_000;
|
|
@@ -36,7 +37,7 @@ export class OsvScanner {
|
|
|
36
37
|
const cacheKey = `osv:${dep.ecosystem}:${dep.name}:${dep.version}`;
|
|
37
38
|
const cached = this.cache.get(cacheKey);
|
|
38
39
|
if (cached !== null) {
|
|
39
|
-
cachedVulns.push(...cached);
|
|
40
|
+
cachedVulns.push(...cached.map((v) => restampDepContext(v, dep)));
|
|
40
41
|
}
|
|
41
42
|
else {
|
|
42
43
|
uncached.push(dep);
|
|
@@ -60,7 +61,7 @@ export class OsvScanner {
|
|
|
60
61
|
const cacheKey = `osv:${dep.ecosystem}:${dep.name}:${dep.version}`;
|
|
61
62
|
const stale = this.cache.getStale(cacheKey);
|
|
62
63
|
if (stale)
|
|
63
|
-
cachedVulns.push(...stale.data);
|
|
64
|
+
cachedVulns.push(...stale.data.map((v) => restampDepContext(v, dep)));
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
}
|
|
@@ -172,9 +173,37 @@ export class OsvScanner {
|
|
|
172
173
|
}
|
|
173
174
|
}
|
|
174
175
|
}
|
|
176
|
+
/**
|
|
177
|
+
* Overwrite the dep-context fields of a cached advisory entry with the CURRENT
|
|
178
|
+
* resolution's context. The advisory facts (id, severity, summary, fix version)
|
|
179
|
+
* are stable and cacheable; `platformActive`, `target`, `sourceDirs`, and the
|
|
180
|
+
* dev/direct flags are functions of TODAY's dependency resolution, not of the
|
|
181
|
+
* advisory. Serving them from cache bakes yesterday's context into today's
|
|
182
|
+
* scan — observed live 2026-08-30: a pre-platform-filter scan cached `glib`
|
|
183
|
+
* as platform-active and `sandbox` with no source dirs, and every scan for the
|
|
184
|
+
* next hour repeated both errors while the fixed resolver sat idle.
|
|
185
|
+
*/
|
|
186
|
+
export function restampDepContext(cached, dep) {
|
|
187
|
+
return {
|
|
188
|
+
...cached,
|
|
189
|
+
package: dep.name,
|
|
190
|
+
currentVersion: dep.version || "unknown",
|
|
191
|
+
ecosystem: dep.ecosystem,
|
|
192
|
+
isDev: dep.isDev,
|
|
193
|
+
isDirect: dep.isDirect,
|
|
194
|
+
devScopeKnown: dep.devScopeKnown,
|
|
195
|
+
target: dep.target,
|
|
196
|
+
platformActive: dep.platformActive,
|
|
197
|
+
sourceDirs: dep.sourceDirs ?? [],
|
|
198
|
+
// Install drift is today's node_modules state, not an advisory fact:
|
|
199
|
+
// stamped from the current dep, so a cached row never carries it forward.
|
|
200
|
+
installDriftOf: dep.installDriftOf,
|
|
201
|
+
installFix: dep.installFix,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
175
204
|
function mapVulnerability(vuln, dep) {
|
|
176
205
|
const { severity, cvssScore } = deriveSeverity(vuln);
|
|
177
|
-
const fixedVersion = extractFixedVersion(vuln.affected, dep.name, dep.ecosystem);
|
|
206
|
+
const fixedVersion = extractFixedVersion(vuln.affected, dep.name, dep.ecosystem, dep.version);
|
|
178
207
|
return {
|
|
179
208
|
package: dep.name,
|
|
180
209
|
currentVersion: dep.version || "unknown",
|
|
@@ -195,6 +224,9 @@ function mapVulnerability(vuln, dep) {
|
|
|
195
224
|
.slice(0, 3),
|
|
196
225
|
target: dep.target,
|
|
197
226
|
platformActive: dep.platformActive,
|
|
227
|
+
sourceDirs: dep.sourceDirs ?? [],
|
|
228
|
+
installDriftOf: dep.installDriftOf,
|
|
229
|
+
installFix: dep.installFix,
|
|
198
230
|
};
|
|
199
231
|
}
|
|
200
232
|
/**
|
|
@@ -260,18 +292,95 @@ function cvssToSeverity(score) {
|
|
|
260
292
|
return "low";
|
|
261
293
|
return "unknown";
|
|
262
294
|
}
|
|
263
|
-
|
|
295
|
+
/**
|
|
296
|
+
* Pick the fix version for the OSV range that actually CONTAINS the installed
|
|
297
|
+
* version — never another release line's fix. The old code returned the first
|
|
298
|
+
* range's first `fixed` event, so a multi-range advisory (e.g. undici fixed at
|
|
299
|
+
* 6.x on the 6 line and 7.x on the 7 line) recommended a DOWNGRADE to 6.x for
|
|
300
|
+
* an installed 7.x.
|
|
301
|
+
*
|
|
302
|
+
* Selection order:
|
|
303
|
+
* 1. The `fixed` of the interval containing installed (introduced <= installed
|
|
304
|
+
* < fixed).
|
|
305
|
+
* 2. If that interval has no fix (or none contains installed), the smallest
|
|
306
|
+
* published fix strictly ABOVE installed across all ranges.
|
|
307
|
+
* 3. If the installed version is unparseable, the highest published fix (it
|
|
308
|
+
* resolves the advisory regardless of release line).
|
|
309
|
+
* 4. Otherwise null — omitting the recommendation beats emitting a downgrade.
|
|
310
|
+
*
|
|
311
|
+
* Hard guard: the returned version is never below the installed version.
|
|
312
|
+
*/
|
|
313
|
+
export function extractFixedVersion(affected, packageName, ecosystem, installedVersion) {
|
|
264
314
|
if (!affected)
|
|
265
315
|
return null;
|
|
316
|
+
const intervals = collectVulnerableIntervals(affected, packageName, ecosystem);
|
|
317
|
+
const fixes = intervals
|
|
318
|
+
.map((i) => i.fixed)
|
|
319
|
+
.filter((f) => f !== null);
|
|
320
|
+
if (fixes.length === 0)
|
|
321
|
+
return null;
|
|
322
|
+
// Ordered the ecosystem's way (PEP 440 for PyPI, `v`-less semver for Go).
|
|
323
|
+
const cmp = (a, b) => compareVersions(a, b, ecosystem);
|
|
324
|
+
if (installedVersion === null || !isComparable(installedVersion, ecosystem)) {
|
|
325
|
+
// Can't place the installed version on a release line — recommend the
|
|
326
|
+
// highest published fix so the upgrade resolves the advisory either way.
|
|
327
|
+
return maxVersion(fixes, ecosystem);
|
|
328
|
+
}
|
|
329
|
+
// The interval whose release line the installed version sits on. An
|
|
330
|
+
// unreadable bound compares as 0, which keeps those intervals eligible on
|
|
331
|
+
// the introduced side only; "0" (since inception) orders below everything.
|
|
332
|
+
const containing = intervals.find((i) => cmp(installedVersion, i.introduced) >= 0 &&
|
|
333
|
+
(i.fixed === null || cmp(installedVersion, i.fixed) < 0));
|
|
334
|
+
if (containing?.fixed && cmp(containing.fixed, installedVersion) > 0) {
|
|
335
|
+
return containing.fixed;
|
|
336
|
+
}
|
|
337
|
+
// No fix on the installed line (open interval, or installed already past
|
|
338
|
+
// every containing range): smallest fix strictly above installed, or nothing.
|
|
339
|
+
const above = fixes.filter((f) => cmp(f, installedVersion) > 0);
|
|
340
|
+
if (above.length === 0)
|
|
341
|
+
return null;
|
|
342
|
+
return above.reduce((min, v) => (cmp(v, min) < 0 ? v : min), above[0]);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Flatten the matching package's version ranges into [introduced, fixed)
|
|
346
|
+
* intervals. OSV sorts events within a range; each `introduced` opens an
|
|
347
|
+
* interval and the next `fixed` closes it. GIT ranges hold commit hashes,
|
|
348
|
+
* not versions, and are skipped.
|
|
349
|
+
*/
|
|
350
|
+
function collectVulnerableIntervals(affected, packageName, ecosystem) {
|
|
351
|
+
const intervals = [];
|
|
266
352
|
for (const a of affected) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
353
|
+
// Names compare the registry's way: PEP 503 for PyPI (`Jinja2` is
|
|
354
|
+
// `jinja2`), `-`/`_` folding for crates. An exact compare found no range
|
|
355
|
+
// for Jinja2 or PyYAML and reported "no fix version published" for both.
|
|
356
|
+
if (a.package.ecosystem !== ecosystem || !samePackageName(a.package.name, packageName, ecosystem))
|
|
357
|
+
continue;
|
|
358
|
+
for (const range of a.ranges || []) {
|
|
359
|
+
if (range.type === "GIT")
|
|
360
|
+
continue;
|
|
361
|
+
let open = null;
|
|
362
|
+
for (const event of range.events || []) {
|
|
363
|
+
if (event.introduced !== undefined) {
|
|
364
|
+
if (open)
|
|
365
|
+
intervals.push(open);
|
|
366
|
+
open = { introduced: event.introduced, fixed: null };
|
|
367
|
+
}
|
|
368
|
+
else if (event.fixed !== undefined) {
|
|
369
|
+
if (open) {
|
|
370
|
+
open.fixed = event.fixed;
|
|
371
|
+
intervals.push(open);
|
|
372
|
+
open = null;
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
// `fixed` with no preceding `introduced` — treat as vulnerable
|
|
376
|
+
// since inception ("0" = OSV's since-the-beginning sentinel).
|
|
377
|
+
intervals.push({ introduced: "0", fixed: event.fixed });
|
|
378
|
+
}
|
|
272
379
|
}
|
|
273
380
|
}
|
|
381
|
+
if (open)
|
|
382
|
+
intervals.push(open);
|
|
274
383
|
}
|
|
275
384
|
}
|
|
276
|
-
return
|
|
385
|
+
return intervals;
|
|
277
386
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry package archive reader (gzip tar), Node built-ins only.
|
|
3
|
+
*
|
|
4
|
+
* `upgrade_impact` reads the changelog a package ships INSIDE its own registry
|
|
5
|
+
* archive (npm `.tgz`, crates.io `.crate`). That keeps the privacy contract
|
|
6
|
+
* simple: the only host contacted is the package's own registry, which the
|
|
7
|
+
* agent was going to download from anyway — never GitHub or a docs site.
|
|
8
|
+
*
|
|
9
|
+
* Why a hand-rolled tar reader: adding `tar` as a dependency for the one job
|
|
10
|
+
* of pulling a few root-level text files would grow the published server's
|
|
11
|
+
* supply chain for no gain. The subset handled here is what real registry
|
|
12
|
+
* archives use: ustar headers with the `prefix` field, PAX `x` headers
|
|
13
|
+
* (`path=` for names over 100 bytes — npm's packer emits these) and GNU `L`
|
|
14
|
+
* long names (older cargo / GNU tar). Everything else is skipped, not guessed.
|
|
15
|
+
*
|
|
16
|
+
* Hard caps, because the archive is third-party input: compressed ≤ 20 MB
|
|
17
|
+
* (Content-Length AND bytes actually read — a server can lie about the
|
|
18
|
+
* former), uncompressed ≤ 80 MB (zlib `maxOutputLength`, so a gzip bomb stops
|
|
19
|
+
* inflating at the cap instead of exhausting memory), single file ≤ 2 MB.
|
|
20
|
+
*/
|
|
21
|
+
export declare const MAX_COMPRESSED_BYTES: number;
|
|
22
|
+
export declare const MAX_UNCOMPRESSED_BYTES: number;
|
|
23
|
+
export declare const MAX_FILE_BYTES: number;
|
|
24
|
+
/** The fetch signature archive and registry readers go through, so tests can inject one. */
|
|
25
|
+
export type FetchFn = (url: string, init?: RequestInit, timeoutMs?: number) => Promise<Response>;
|
|
26
|
+
export declare const defaultFetch: FetchFn;
|
|
27
|
+
/** Raised for every refusal (cap exceeded, corrupt archive) with a message fit for tool output. */
|
|
28
|
+
export declare class ArchiveError extends Error {
|
|
29
|
+
}
|
|
30
|
+
interface TarEntry {
|
|
31
|
+
path: string;
|
|
32
|
+
type: string;
|
|
33
|
+
data: Buffer;
|
|
34
|
+
}
|
|
35
|
+
/** PAX records are `"<len> <key>=<value>\n"`, where len counts the whole record in BYTES. */
|
|
36
|
+
export declare function parsePaxHeaders(data: Buffer): Record<string, string>;
|
|
37
|
+
/** Walk an uncompressed tar buffer, yielding regular-file entries with their resolved names. */
|
|
38
|
+
export declare function iterateTar(tar: Buffer): Generator<TarEntry>;
|
|
39
|
+
/**
|
|
40
|
+
* Root-level text files from a gzip tar whose basename `accept` approves.
|
|
41
|
+
* "Root-level" = at most one directory deep, because registry archives wrap
|
|
42
|
+
* everything in a single top directory (`package/`, `<name>-<version>/`).
|
|
43
|
+
* Keys are the archive paths as stored. Oversized or binary (NUL-containing)
|
|
44
|
+
* files are skipped.
|
|
45
|
+
*/
|
|
46
|
+
export declare function readRootTextFiles(gz: Buffer, accept: (basename: string) => boolean): Map<string, string>;
|
|
47
|
+
/** Download a registry archive (size-capped) and return the accepted root-level text files. */
|
|
48
|
+
export declare function fetchArchiveRootFiles(url: string, accept: (basename: string) => boolean, fetchFn?: FetchFn, headers?: Record<string, string>): Promise<Map<string, string>>;
|
|
49
|
+
export {};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Registry package archive reader (gzip tar), Node built-ins only.
|
|
4
|
+
*
|
|
5
|
+
* `upgrade_impact` reads the changelog a package ships INSIDE its own registry
|
|
6
|
+
* archive (npm `.tgz`, crates.io `.crate`). That keeps the privacy contract
|
|
7
|
+
* simple: the only host contacted is the package's own registry, which the
|
|
8
|
+
* agent was going to download from anyway — never GitHub or a docs site.
|
|
9
|
+
*
|
|
10
|
+
* Why a hand-rolled tar reader: adding `tar` as a dependency for the one job
|
|
11
|
+
* of pulling a few root-level text files would grow the published server's
|
|
12
|
+
* supply chain for no gain. The subset handled here is what real registry
|
|
13
|
+
* archives use: ustar headers with the `prefix` field, PAX `x` headers
|
|
14
|
+
* (`path=` for names over 100 bytes — npm's packer emits these) and GNU `L`
|
|
15
|
+
* long names (older cargo / GNU tar). Everything else is skipped, not guessed.
|
|
16
|
+
*
|
|
17
|
+
* Hard caps, because the archive is third-party input: compressed ≤ 20 MB
|
|
18
|
+
* (Content-Length AND bytes actually read — a server can lie about the
|
|
19
|
+
* former), uncompressed ≤ 80 MB (zlib `maxOutputLength`, so a gzip bomb stops
|
|
20
|
+
* inflating at the cap instead of exhausting memory), single file ≤ 2 MB.
|
|
21
|
+
*/
|
|
22
|
+
import { gunzipSync } from "node:zlib";
|
|
23
|
+
import { fetchWithTimeout } from "./http-utils.js";
|
|
24
|
+
export const MAX_COMPRESSED_BYTES = 20 * 1024 * 1024;
|
|
25
|
+
export const MAX_UNCOMPRESSED_BYTES = 80 * 1024 * 1024;
|
|
26
|
+
export const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
27
|
+
const BLOCK = 512;
|
|
28
|
+
const ARCHIVE_TIMEOUT_MS = 30_000;
|
|
29
|
+
export const defaultFetch = (url, init, timeoutMs) => fetchWithTimeout(url, init ?? {}, timeoutMs);
|
|
30
|
+
/** Raised for every refusal (cap exceeded, corrupt archive) with a message fit for tool output. */
|
|
31
|
+
export class ArchiveError extends Error {
|
|
32
|
+
}
|
|
33
|
+
function readString(buf, start, length) {
|
|
34
|
+
const slice = buf.subarray(start, start + length);
|
|
35
|
+
const nul = slice.indexOf(0);
|
|
36
|
+
return slice.subarray(0, nul === -1 ? slice.length : nul).toString("utf8");
|
|
37
|
+
}
|
|
38
|
+
function readOctal(buf, start, length) {
|
|
39
|
+
if (buf[start] & 0x80) {
|
|
40
|
+
// GNU base-256 size: only needed for entries ≥ 8 GB, far over every cap here.
|
|
41
|
+
throw new ArchiveError("tar entry uses a base-256 size field (entry too large)");
|
|
42
|
+
}
|
|
43
|
+
const text = readString(buf, start, length).trim();
|
|
44
|
+
if (text === "")
|
|
45
|
+
return 0;
|
|
46
|
+
if (!/^[0-7]+$/.test(text))
|
|
47
|
+
throw new ArchiveError(`corrupt tar header: size field "${text}"`);
|
|
48
|
+
return parseInt(text, 8);
|
|
49
|
+
}
|
|
50
|
+
function checksumMatches(header) {
|
|
51
|
+
const stored = readString(header, 148, 8).trim();
|
|
52
|
+
if (!/^[0-7]+$/.test(stored))
|
|
53
|
+
return false;
|
|
54
|
+
let sum = 0;
|
|
55
|
+
for (let i = 0; i < BLOCK; i++)
|
|
56
|
+
sum += i >= 148 && i < 156 ? 0x20 : header[i];
|
|
57
|
+
return sum === parseInt(stored, 8);
|
|
58
|
+
}
|
|
59
|
+
/** PAX records are `"<len> <key>=<value>\n"`, where len counts the whole record in BYTES. */
|
|
60
|
+
export function parsePaxHeaders(data) {
|
|
61
|
+
const out = {};
|
|
62
|
+
let pos = 0;
|
|
63
|
+
while (pos < data.length) {
|
|
64
|
+
const space = data.indexOf(0x20, pos);
|
|
65
|
+
if (space === -1)
|
|
66
|
+
break;
|
|
67
|
+
const len = Number(data.subarray(pos, space).toString("ascii"));
|
|
68
|
+
if (!Number.isInteger(len) || len <= 0 || pos + len > data.length)
|
|
69
|
+
break;
|
|
70
|
+
const record = data.subarray(space + 1, pos + len).toString("utf8").replace(/\n$/, "");
|
|
71
|
+
const eq = record.indexOf("=");
|
|
72
|
+
if (eq > 0)
|
|
73
|
+
out[record.slice(0, eq)] = record.slice(eq + 1);
|
|
74
|
+
pos += len;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** Walk an uncompressed tar buffer, yielding regular-file entries with their resolved names. */
|
|
79
|
+
export function* iterateTar(tar) {
|
|
80
|
+
let offset = 0;
|
|
81
|
+
let paxPath = null;
|
|
82
|
+
let gnuLongName = null;
|
|
83
|
+
while (offset + BLOCK <= tar.length) {
|
|
84
|
+
const header = tar.subarray(offset, offset + BLOCK);
|
|
85
|
+
if (header.every((b) => b === 0))
|
|
86
|
+
return; // end-of-archive marker
|
|
87
|
+
if (!checksumMatches(header))
|
|
88
|
+
throw new ArchiveError("corrupt tar header: checksum mismatch");
|
|
89
|
+
const size = readOctal(header, 124, 12);
|
|
90
|
+
const type = String.fromCharCode(header[156] || 0x30); // NUL typeflag = regular file
|
|
91
|
+
const dataStart = offset + BLOCK;
|
|
92
|
+
if (dataStart + size > tar.length)
|
|
93
|
+
throw new ArchiveError("truncated tar entry");
|
|
94
|
+
const data = tar.subarray(dataStart, dataStart + size);
|
|
95
|
+
offset = dataStart + Math.ceil(size / BLOCK) * BLOCK;
|
|
96
|
+
if (type === "x") {
|
|
97
|
+
paxPath = parsePaxHeaders(data).path ?? null;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (type === "L") {
|
|
101
|
+
gnuLongName = readString(data, 0, data.length);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (type === "g" || type === "K")
|
|
105
|
+
continue; // global PAX / GNU long link: no effect on names here
|
|
106
|
+
const name = readString(header, 0, 100);
|
|
107
|
+
const magic = readString(header, 257, 6);
|
|
108
|
+
const prefix = magic.startsWith("ustar") ? readString(header, 345, 155) : "";
|
|
109
|
+
const path = paxPath ?? gnuLongName ?? (prefix ? `${prefix}/${name}` : name);
|
|
110
|
+
paxPath = null;
|
|
111
|
+
gnuLongName = null;
|
|
112
|
+
yield { path, type, data };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Path segments with `./` and empty segments removed. */
|
|
116
|
+
function segments(path) {
|
|
117
|
+
return path.split("/").filter((s) => s !== "" && s !== ".");
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Root-level text files from a gzip tar whose basename `accept` approves.
|
|
121
|
+
* "Root-level" = at most one directory deep, because registry archives wrap
|
|
122
|
+
* everything in a single top directory (`package/`, `<name>-<version>/`).
|
|
123
|
+
* Keys are the archive paths as stored. Oversized or binary (NUL-containing)
|
|
124
|
+
* files are skipped.
|
|
125
|
+
*/
|
|
126
|
+
export function readRootTextFiles(gz, accept) {
|
|
127
|
+
if (gz.length > MAX_COMPRESSED_BYTES) {
|
|
128
|
+
throw new ArchiveError(`archive is ${gz.length} bytes compressed (cap ${MAX_COMPRESSED_BYTES})`);
|
|
129
|
+
}
|
|
130
|
+
let tar;
|
|
131
|
+
try {
|
|
132
|
+
tar = gunzipSync(gz, { maxOutputLength: MAX_UNCOMPRESSED_BYTES });
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
if (err instanceof RangeError || err.code === "ERR_BUFFER_TOO_LARGE") {
|
|
136
|
+
throw new ArchiveError(`archive exceeds ${MAX_UNCOMPRESSED_BYTES} bytes uncompressed`);
|
|
137
|
+
}
|
|
138
|
+
throw new ArchiveError(`archive is not valid gzip: ${err.message}`);
|
|
139
|
+
}
|
|
140
|
+
const files = new Map();
|
|
141
|
+
for (const entry of iterateTar(tar)) {
|
|
142
|
+
if (entry.type !== "0" && entry.type !== "7")
|
|
143
|
+
continue;
|
|
144
|
+
const parts = segments(entry.path);
|
|
145
|
+
if (parts.length === 0 || parts.length > 2)
|
|
146
|
+
continue;
|
|
147
|
+
if (!accept(parts[parts.length - 1]))
|
|
148
|
+
continue;
|
|
149
|
+
if (entry.data.length > MAX_FILE_BYTES || entry.data.includes(0))
|
|
150
|
+
continue;
|
|
151
|
+
files.set(entry.path, entry.data.toString("utf8"));
|
|
152
|
+
}
|
|
153
|
+
return files;
|
|
154
|
+
}
|
|
155
|
+
/** Read a response body, refusing at `cap` bytes instead of buffering whatever the server sends. */
|
|
156
|
+
async function readCapped(response, cap) {
|
|
157
|
+
const declared = Number(response.headers.get("content-length"));
|
|
158
|
+
if (Number.isFinite(declared) && declared > cap) {
|
|
159
|
+
throw new ArchiveError(`archive is ${declared} bytes compressed (cap ${cap})`);
|
|
160
|
+
}
|
|
161
|
+
if (!response.body)
|
|
162
|
+
return Buffer.from(await response.arrayBuffer());
|
|
163
|
+
const reader = response.body.getReader();
|
|
164
|
+
const chunks = [];
|
|
165
|
+
let total = 0;
|
|
166
|
+
for (;;) {
|
|
167
|
+
const { done, value } = await reader.read();
|
|
168
|
+
if (done)
|
|
169
|
+
break;
|
|
170
|
+
total += value.byteLength;
|
|
171
|
+
if (total > cap) {
|
|
172
|
+
await reader.cancel().catch(() => undefined);
|
|
173
|
+
throw new ArchiveError(`archive exceeds ${cap} bytes compressed`);
|
|
174
|
+
}
|
|
175
|
+
chunks.push(Buffer.from(value));
|
|
176
|
+
}
|
|
177
|
+
return Buffer.concat(chunks);
|
|
178
|
+
}
|
|
179
|
+
/** Download a registry archive (size-capped) and return the accepted root-level text files. */
|
|
180
|
+
export async function fetchArchiveRootFiles(url, accept, fetchFn = defaultFetch, headers = {}) {
|
|
181
|
+
const response = await fetchFn(url, { headers }, ARCHIVE_TIMEOUT_MS);
|
|
182
|
+
if (!response.ok)
|
|
183
|
+
throw new ArchiveError(`archive download failed: HTTP ${response.status}`);
|
|
184
|
+
const gz = await readCapped(response, MAX_COMPRESSED_BYTES);
|
|
185
|
+
return readRootTextFiles(gz, accept);
|
|
186
|
+
}
|
|
@@ -21,7 +21,9 @@ export class PyPIRegistry {
|
|
|
21
21
|
}
|
|
22
22
|
async getPackageInfo(name, currentVersion, isDev) {
|
|
23
23
|
const normalizedName = name.toLowerCase().replace(/_/g, "-");
|
|
24
|
-
|
|
24
|
+
// Keyed by the installed version too: the cached answer carries currentVersion
|
|
25
|
+
// and its distance, so a name-only key served one project's answer to another.
|
|
26
|
+
const cacheKey = `pypi-reg:${name.toLowerCase()}@${currentVersion ?? ""}:${isDev ? 1 : 0}`;
|
|
25
27
|
const cached = this.cache.get(cacheKey);
|
|
26
28
|
if (cached !== null)
|
|
27
29
|
return cached;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry health lookups for resolved dependencies (moved out of
|
|
3
|
+
* live/index.ts unchanged, so the coordinator stays within its size limit).
|
|
4
|
+
*/
|
|
5
|
+
import type { NpmRegistry } from "./npm-registry.js";
|
|
6
|
+
import type { CratesRegistry } from "./crates-registry.js";
|
|
7
|
+
import type { PyPIRegistry } from "./pypi-registry.js";
|
|
8
|
+
import type { GoRegistry } from "./go-registry.js";
|
|
9
|
+
import type { RegistryPackageInfo, ResolvedDependency } from "./types.js";
|
|
10
|
+
export interface Registries {
|
|
11
|
+
npm: NpmRegistry;
|
|
12
|
+
crates: CratesRegistry;
|
|
13
|
+
pypi: PyPIRegistry;
|
|
14
|
+
go: GoRegistry;
|
|
15
|
+
}
|
|
16
|
+
export declare function fetchRegistryHealthFor(deps: ResolvedDependency[], registries: Registries, enabled: boolean): Promise<RegistryPackageInfo[]>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Registry health lookups for resolved dependencies (moved out of
|
|
4
|
+
* live/index.ts unchanged, so the coordinator stays within its size limit).
|
|
5
|
+
*/
|
|
6
|
+
import { computeSemverDistance } from "./semver-utils.js";
|
|
7
|
+
function unavailable(dep, fetchError) {
|
|
8
|
+
return {
|
|
9
|
+
name: dep.name, ecosystem: dep.ecosystem, currentVersion: dep.version,
|
|
10
|
+
latestVersion: null, latestStableVersion: null, versionsBehind: null,
|
|
11
|
+
deprecated: false, deprecationMessage: null, lastPublished: null,
|
|
12
|
+
license: null, weeklyDownloads: null, isDev: dep.isDev, fetchError,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
export async function fetchRegistryHealthFor(deps, registries, enabled) {
|
|
16
|
+
if (!enabled)
|
|
17
|
+
return deps.map((d) => unavailable(d, "Offline mode"));
|
|
18
|
+
const registryForEcosystem = (eco) => {
|
|
19
|
+
switch (eco) {
|
|
20
|
+
case "npm": return registries.npm;
|
|
21
|
+
case "crates.io": return registries.crates;
|
|
22
|
+
case "PyPI": return registries.pypi;
|
|
23
|
+
case "Go": return registries.go;
|
|
24
|
+
default: return null;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const results = await Promise.all(deps.map(async (dep) => {
|
|
28
|
+
const registry = registryForEcosystem(dep.ecosystem);
|
|
29
|
+
if (!registry)
|
|
30
|
+
return unavailable(dep, `No registry fetcher for ${dep.ecosystem}`);
|
|
31
|
+
try {
|
|
32
|
+
const info = await registry.getPackageInfo(dep.name, dep.version, dep.isDev);
|
|
33
|
+
return restampRegistryContext(info, dep);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return unavailable(dep, "Registry fetch failed");
|
|
37
|
+
}
|
|
38
|
+
}));
|
|
39
|
+
// Bulk fetch npm downloads for npm deps
|
|
40
|
+
const npmDeps = deps.filter((d) => d.ecosystem === "npm");
|
|
41
|
+
if (npmDeps.length > 0) {
|
|
42
|
+
try {
|
|
43
|
+
const downloads = await registries.npm.getBulkDownloads(npmDeps.map((d) => d.name));
|
|
44
|
+
for (const result of results) {
|
|
45
|
+
if (result.ecosystem === "npm" && downloads.has(result.name)) {
|
|
46
|
+
result.weeklyDownloads = downloads.get(result.name) || null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Downloads are nice-to-have, not critical
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Registry caches key by package NAME, but the cached record embeds the
|
|
58
|
+
* QUERYING dependency's `currentVersion`, `versionsBehind`, and `isDev`. Two
|
|
59
|
+
* instances of one package at different versions (better-sqlite3 11.10.0 and
|
|
60
|
+
* 12.11.1 across workspaces) therefore both came back wearing the first
|
|
61
|
+
* instance's version — the upgrade planner then showed two identical rows and
|
|
62
|
+
* lost the older instance entirely. Registry facts (latest version,
|
|
63
|
+
* deprecation, downloads) are per-package and cacheable; the per-instance
|
|
64
|
+
* fields are re-stamped here from the dep actually being asked about.
|
|
65
|
+
*/
|
|
66
|
+
function restampRegistryContext(info, dep) {
|
|
67
|
+
const latest = info.latestStableVersion || info.latestVersion;
|
|
68
|
+
return {
|
|
69
|
+
...info,
|
|
70
|
+
currentVersion: dep.version,
|
|
71
|
+
isDev: dep.isDev,
|
|
72
|
+
versionsBehind: dep.version && latest ? computeSemverDistance(dep.version, latest) : null,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-release registry metadata, normalised across npm and crates.io.
|
|
3
|
+
*
|
|
4
|
+
* `dependency_check` judges ONE proposed version against the one installed
|
|
5
|
+
* (or, for a new dependency, against the previous release on the same line).
|
|
6
|
+
* That needs facts the version-freshness readers never kept: when each release
|
|
7
|
+
* was published, who/what published it, whether it runs install scripts, and
|
|
8
|
+
* which runtime dependencies it pulls in. Both registry readers project their
|
|
9
|
+
* raw responses into this shape so the signal logic never sees a registry's
|
|
10
|
+
* wire format.
|
|
11
|
+
*
|
|
12
|
+
* Privacy: every request behind this shape carries the package NAME only.
|
|
13
|
+
* Versions are selected locally from the full release list.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Publish trust of one release.
|
|
17
|
+
* 2 = published by a registry-verified trusted publisher (npm
|
|
18
|
+
* `_npmUser.trustedPublisher`, crates.io `trustpub_data`).
|
|
19
|
+
* 1 = carries a provenance attestation (npm `dist.attestations`).
|
|
20
|
+
* 0 = neither: a token / hand publish.
|
|
21
|
+
*/
|
|
22
|
+
export type TrustLevel = 0 | 1 | 2;
|
|
23
|
+
export interface ReleaseRecord {
|
|
24
|
+
version: string;
|
|
25
|
+
/** ISO publish time, or null when the registry does not say. */
|
|
26
|
+
publishedAt: string | null;
|
|
27
|
+
trust: TrustLevel;
|
|
28
|
+
/** Human label for the trust evidence, e.g. "trusted publisher (github)". */
|
|
29
|
+
trustEvidence: string;
|
|
30
|
+
/** npm: install-time lifecycle scripts declared (preinstall/install/postinstall, or a gypfile). Null for crates. */
|
|
31
|
+
installScripts: string[] | null;
|
|
32
|
+
/** npm deprecation message, or null. */
|
|
33
|
+
deprecated: string | null;
|
|
34
|
+
/** crates.io: yanked. npm: unpublished (listed in `time` but gone from `versions`). */
|
|
35
|
+
withdrawn: "yanked" | "unpublished" | null;
|
|
36
|
+
/** Runtime dependency NAMES (npm dependencies + optionalDependencies; crates normal + build). Null when unknown. */
|
|
37
|
+
dependencies: string[] | null;
|
|
38
|
+
}
|
|
39
|
+
export interface PackageReleaseIndex {
|
|
40
|
+
ecosystem: "npm" | "crates.io";
|
|
41
|
+
name: string;
|
|
42
|
+
/** When the package itself was first published, or null. */
|
|
43
|
+
createdAt: string | null;
|
|
44
|
+
releases: Record<string, ReleaseRecord>;
|
|
45
|
+
}
|
|
46
|
+
/** The outcome of one registry read: data, a definite "no such package", or "could not reach it". */
|
|
47
|
+
export type RegistryLookup<T> = {
|
|
48
|
+
status: "ok";
|
|
49
|
+
data: T;
|
|
50
|
+
} | {
|
|
51
|
+
status: "not_found";
|
|
52
|
+
detail: string;
|
|
53
|
+
} | {
|
|
54
|
+
status: "unreachable";
|
|
55
|
+
detail: string;
|
|
56
|
+
};
|
|
57
|
+
/** What dependency_check needs from a registry. Live readers and test fakes both implement it. */
|
|
58
|
+
export interface ReleaseMetadataSource {
|
|
59
|
+
/**
|
|
60
|
+
* The package's release index. `wanted` names the versions the caller is
|
|
61
|
+
* about to read; a reader that pages (crates.io) may stop once it has them
|
|
62
|
+
* and the release just below `wanted[0]` on its line.
|
|
63
|
+
*/
|
|
64
|
+
getReleases(name: string, wanted: string[]): Promise<RegistryLookup<PackageReleaseIndex>>;
|
|
65
|
+
/** When a package was first published (for a newly introduced transitive dependency). */
|
|
66
|
+
getCreatedAt(name: string): Promise<RegistryLookup<string | null>>;
|
|
67
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Per-release registry metadata, normalised across npm and crates.io.
|
|
4
|
+
*
|
|
5
|
+
* `dependency_check` judges ONE proposed version against the one installed
|
|
6
|
+
* (or, for a new dependency, against the previous release on the same line).
|
|
7
|
+
* That needs facts the version-freshness readers never kept: when each release
|
|
8
|
+
* was published, who/what published it, whether it runs install scripts, and
|
|
9
|
+
* which runtime dependencies it pulls in. Both registry readers project their
|
|
10
|
+
* raw responses into this shape so the signal logic never sees a registry's
|
|
11
|
+
* wire format.
|
|
12
|
+
*
|
|
13
|
+
* Privacy: every request behind this shape carries the package NAME only.
|
|
14
|
+
* Versions are selected locally from the full release list.
|
|
15
|
+
*/
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency resolution that knows when it has gone stale.
|
|
3
|
+
*
|
|
4
|
+
* Measured 2026-09-10: three `node ./mcp-4da-server/dist/index.js` processes
|
|
5
|
+
* were live and two had started before the pull that brought hono 4.13.5.
|
|
6
|
+
* `vulnerability_scan` from one of them reported hono 4.13.3 (the lockfile
|
|
7
|
+
* value when it started) with `_meta.cached: false`. The live layer resolved
|
|
8
|
+
* versions once at init and never again, and `cached` described only the OSV
|
|
9
|
+
* lookup, so nothing in the answer could reveal that it was stale.
|
|
10
|
+
*
|
|
11
|
+
* A group records every file its resolution depended on as a stat signature:
|
|
12
|
+
* each lockfile candidate (present or absent, so one appearing or vanishing
|
|
13
|
+
* counts), the manifest when the resolver fell back to it, and for npm the
|
|
14
|
+
* node_modules install state. Signatures are taken BEFORE reading, so a write
|
|
15
|
+
* that lands mid-read still registers as a change. `resolveGroup` depends
|
|
16
|
+
* only on its inputs and the filesystem, so the SAME resolution can be re-run
|
|
17
|
+
* when a signature moves. Stat calls only: no timers, no watchers.
|
|
18
|
+
*/
|
|
19
|
+
import { type FileSignature } from "./file-signature.js";
|
|
20
|
+
import type { InstallDriftRecord, ResolutionSourceRecord, ResolvedDependency } from "./types.js";
|
|
21
|
+
/** The inputs of one resolution, kept so the same resolution can be re-run. */
|
|
22
|
+
export interface ResolutionGroup {
|
|
23
|
+
dir: string;
|
|
24
|
+
language: string;
|
|
25
|
+
deps: string[];
|
|
26
|
+
devDeps: string[];
|
|
27
|
+
targets: Record<string, string>;
|
|
28
|
+
}
|
|
29
|
+
export interface GroupResolution {
|
|
30
|
+
group: ResolutionGroup;
|
|
31
|
+
/** Direct dependencies (dependency health, upgrade planning). */
|
|
32
|
+
resolved: ResolvedDependency[];
|
|
33
|
+
/** The full lockfile set plus drift entries (vulnerability scanning). */
|
|
34
|
+
audit: ResolvedDependency[];
|
|
35
|
+
drift: InstallDriftRecord[];
|
|
36
|
+
/** The file the versions were read from, or null when the group resolved from nothing. */
|
|
37
|
+
source: ResolutionSourceRecord | null;
|
|
38
|
+
signatures: Map<string, FileSignature>;
|
|
39
|
+
resolvedAt: string;
|
|
40
|
+
}
|
|
41
|
+
export declare function resolveGroup(group: ResolutionGroup): GroupResolution;
|
|
42
|
+
/** True when any file the resolution depended on changed, appeared, or vanished. */
|
|
43
|
+
export declare function groupIsStale(result: GroupResolution): boolean;
|