@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.
Files changed (172) hide show
  1. package/CHANGELOG.md +667 -0
  2. package/README.md +66 -35
  3. package/dist/build-staleness.d.ts +35 -0
  4. package/dist/build-staleness.js +95 -0
  5. package/dist/db.d.ts +53 -2
  6. package/dist/db.js +242 -36
  7. package/dist/doctor.js +27 -7
  8. package/dist/embeddings.js +7 -1
  9. package/dist/freshness-markers.d.ts +34 -0
  10. package/dist/freshness-markers.js +74 -0
  11. package/dist/gitignore.d.ts +37 -0
  12. package/dist/gitignore.js +130 -0
  13. package/dist/index.js +127 -44
  14. package/dist/live/bun-lockfile.d.ts +26 -0
  15. package/dist/live/bun-lockfile.js +123 -0
  16. package/dist/live/cargo-platform.d.ts +38 -0
  17. package/dist/live/cargo-platform.js +178 -0
  18. package/dist/live/changelog-classify.d.ts +41 -0
  19. package/dist/live/changelog-classify.js +207 -0
  20. package/dist/live/changelog.d.ts +64 -0
  21. package/dist/live/changelog.js +318 -0
  22. package/dist/live/crates-registry.js +3 -1
  23. package/dist/live/crates-versions.d.ts +47 -0
  24. package/dist/live/crates-versions.js +201 -0
  25. package/dist/live/dependency-set.d.ts +13 -0
  26. package/dist/live/dependency-set.js +95 -0
  27. package/dist/live/dev-scope.d.ts +32 -0
  28. package/dist/live/dev-scope.js +102 -0
  29. package/dist/live/file-signature.d.ts +18 -0
  30. package/dist/live/file-signature.js +43 -0
  31. package/dist/live/go-registry.js +3 -1
  32. package/dist/live/hn-fetcher.js +9 -3
  33. package/dist/live/index.d.ts +105 -2
  34. package/dist/live/index.js +266 -139
  35. package/dist/live/install-state.d.ts +43 -0
  36. package/dist/live/install-state.js +150 -0
  37. package/dist/live/js-lockfile-readers.d.ts +46 -0
  38. package/dist/live/js-lockfile-readers.js +181 -0
  39. package/dist/live/lockfile-parsers-pygo.d.ts +60 -0
  40. package/dist/live/lockfile-parsers-pygo.js +274 -0
  41. package/dist/live/lockfile-parsers.d.ts +69 -0
  42. package/dist/live/lockfile-parsers.js +311 -0
  43. package/dist/live/lockfile-types.d.ts +34 -0
  44. package/dist/live/lockfile-types.js +42 -0
  45. package/dist/live/maintenance.d.ts +33 -0
  46. package/dist/live/maintenance.js +37 -0
  47. package/dist/live/npm-packument.d.ts +47 -0
  48. package/dist/live/npm-packument.js +150 -0
  49. package/dist/live/npm-registry.js +67 -53
  50. package/dist/live/osv-scanner.d.ts +31 -1
  51. package/dist/live/osv-scanner.js +119 -10
  52. package/dist/live/package-archive.d.ts +49 -0
  53. package/dist/live/package-archive.js +186 -0
  54. package/dist/live/pypi-registry.js +3 -1
  55. package/dist/live/registry-health.d.ts +16 -0
  56. package/dist/live/registry-health.js +74 -0
  57. package/dist/live/release-metadata.d.ts +67 -0
  58. package/dist/live/release-metadata.js +16 -0
  59. package/dist/live/resolution.d.ts +43 -0
  60. package/dist/live/resolution.js +62 -0
  61. package/dist/live/semver-precedence.d.ts +30 -0
  62. package/dist/live/semver-precedence.js +83 -0
  63. package/dist/live/severity-scope.d.ts +57 -0
  64. package/dist/live/severity-scope.js +89 -0
  65. package/dist/live/types.d.ts +83 -0
  66. package/dist/live/upgrade-sources.d.ts +71 -0
  67. package/dist/live/upgrade-sources.js +198 -0
  68. package/dist/live/version-compare.d.ts +39 -0
  69. package/dist/live/version-compare.js +130 -0
  70. package/dist/live/version-resolver.d.ts +14 -1
  71. package/dist/live/version-resolver.js +93 -303
  72. package/dist/native-bindings.d.ts +19 -0
  73. package/dist/native-bindings.js +39 -0
  74. package/dist/project-manifests.d.ts +40 -0
  75. package/dist/project-manifests.js +193 -0
  76. package/dist/project-scanner.js +38 -48
  77. package/dist/project-tree.d.ts +41 -0
  78. package/dist/project-tree.js +110 -0
  79. package/dist/prompts.d.ts +28 -0
  80. package/dist/prompts.js +54 -0
  81. package/dist/schema-registry.d.ts +41 -29
  82. package/dist/schema-registry.js +98 -105
  83. package/dist/server-instructions.d.ts +11 -0
  84. package/dist/server-instructions.js +36 -0
  85. package/dist/tool-args.d.ts +28 -0
  86. package/dist/tool-args.js +93 -0
  87. package/dist/tool-dispatch.d.ts +1 -1
  88. package/dist/tool-dispatch.js +42 -8
  89. package/dist/tools/app-plan.d.ts +92 -0
  90. package/dist/tools/app-plan.js +155 -0
  91. package/dist/tools/briefing-stored-advisories.d.ts +20 -0
  92. package/dist/tools/briefing-stored-advisories.js +58 -0
  93. package/dist/tools/briefing-task-scope.d.ts +73 -0
  94. package/dist/tools/briefing-task-scope.js +197 -0
  95. package/dist/tools/briefing-wisdom.d.ts +31 -0
  96. package/dist/tools/briefing-wisdom.js +148 -0
  97. package/dist/tools/decision-enforcement.d.ts +0 -5
  98. package/dist/tools/decision-enforcement.js +0 -4
  99. package/dist/tools/decision-memory.js +18 -66
  100. package/dist/tools/dependency-check-signals.d.ts +74 -0
  101. package/dist/tools/dependency-check-signals.js +264 -0
  102. package/dist/tools/dependency-check.d.ts +98 -0
  103. package/dist/tools/dependency-check.js +295 -0
  104. package/dist/tools/dependency-health.js +74 -21
  105. package/dist/tools/developer-dna.d.ts +3 -0
  106. package/dist/tools/developer-dna.js +16 -15
  107. package/dist/tools/ecosystem-pulse.d.ts +6 -2
  108. package/dist/tools/ecosystem-pulse.js +53 -14
  109. package/dist/tools/get-actionable-signals.d.ts +26 -4
  110. package/dist/tools/get-actionable-signals.js +214 -251
  111. package/dist/tools/get-context.d.ts +2 -0
  112. package/dist/tools/get-context.js +9 -1
  113. package/dist/tools/get-relevant-content.d.ts +3 -1
  114. package/dist/tools/get-relevant-content.js +34 -3
  115. package/dist/tools/index.d.ts +4 -2
  116. package/dist/tools/index.js +4 -2
  117. package/dist/tools/install-drift-notes.d.ts +51 -0
  118. package/dist/tools/install-drift-notes.js +172 -0
  119. package/dist/tools/knowledge-gap-citations.d.ts +55 -0
  120. package/dist/tools/knowledge-gap-citations.js +132 -0
  121. package/dist/tools/knowledge-gap-exposure.d.ts +72 -0
  122. package/dist/tools/knowledge-gap-exposure.js +160 -0
  123. package/dist/tools/knowledge-gap-grading.d.ts +91 -0
  124. package/dist/tools/knowledge-gap-grading.js +157 -0
  125. package/dist/tools/knowledge-gap-installs.d.ts +65 -0
  126. package/dist/tools/knowledge-gap-installs.js +134 -0
  127. package/dist/tools/knowledge-gap-ranges.d.ts +51 -0
  128. package/dist/tools/knowledge-gap-ranges.js +146 -0
  129. package/dist/tools/knowledge-gap-scope.d.ts +63 -0
  130. package/dist/tools/knowledge-gap-scope.js +118 -0
  131. package/dist/tools/knowledge-gaps.d.ts +12 -3
  132. package/dist/tools/knowledge-gaps.js +172 -121
  133. package/dist/tools/package-presence.d.ts +24 -0
  134. package/dist/tools/package-presence.js +35 -0
  135. package/dist/tools/record-feedback.d.ts +0 -1
  136. package/dist/tools/record-feedback.js +7 -8
  137. package/dist/tools/signal-classifier.d.ts +25 -0
  138. package/dist/tools/signal-classifier.js +40 -0
  139. package/dist/tools/upgrade-impact-callsites.d.ts +52 -0
  140. package/dist/tools/upgrade-impact-callsites.js +226 -0
  141. package/dist/tools/upgrade-impact-literals.d.ts +45 -0
  142. package/dist/tools/upgrade-impact-literals.js +118 -0
  143. package/dist/tools/upgrade-impact-report.d.ts +80 -0
  144. package/dist/tools/upgrade-impact-report.js +178 -0
  145. package/dist/tools/upgrade-impact.d.ts +84 -0
  146. package/dist/tools/upgrade-impact.js +305 -0
  147. package/dist/tools/upgrade-planner.d.ts +41 -8
  148. package/dist/tools/upgrade-planner.js +201 -53
  149. package/dist/tools/vulnerability-scan-concise.d.ts +72 -0
  150. package/dist/tools/vulnerability-scan-concise.js +97 -0
  151. package/dist/tools/vulnerability-scan-format.d.ts +137 -0
  152. package/dist/tools/vulnerability-scan-format.js +276 -0
  153. package/dist/tools/vulnerability-scan.d.ts +338 -18
  154. package/dist/tools/vulnerability-scan.js +84 -125
  155. package/dist/tools/what-should-i-know.d.ts +86 -19
  156. package/dist/tools/what-should-i-know.js +277 -283
  157. package/dist/types.d.ts +4 -1
  158. package/package.json +27 -30
  159. package/dist/schemas/agent-memory.json +0 -49
  160. package/dist/schemas/decision-enforcement.json +0 -22
  161. package/dist/schemas/decision-memory.json +0 -54
  162. package/dist/schemas/dependency-health.json +0 -27
  163. package/dist/schemas/developer-dna.json +0 -219
  164. package/dist/schemas/ecosystem-pulse.json +0 -17
  165. package/dist/schemas/get-actionable-signals.json +0 -29
  166. package/dist/schemas/get-context.json +0 -17
  167. package/dist/schemas/get-relevant-content.json +0 -26
  168. package/dist/schemas/knowledge-gaps.json +0 -14
  169. package/dist/schemas/record-feedback.json +0 -24
  170. package/dist/schemas/upgrade-planner.json +0 -22
  171. package/dist/schemas/vulnerability-scan.json +0 -26
  172. package/dist/schemas/what-should-i-know.json +0 -19
@@ -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
- function extractFixedVersion(affected, packageName, ecosystem) {
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
- if (a.package.name === packageName && a.package.ecosystem === ecosystem) {
268
- for (const range of a.ranges || []) {
269
- for (const event of range.events || []) {
270
- if (event.fixed)
271
- return event.fixed;
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 null;
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
- const cacheKey = `pypi-reg:${name.toLowerCase()}`;
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;