@4da/mcp-server 5.0.4 → 6.0.1

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 +698 -0
  2. package/README.md +70 -39
  3. package/dist/build-staleness.d.ts +35 -0
  4. package/dist/build-staleness.js +95 -0
  5. package/dist/db.d.ts +53 -58
  6. package/dist/db.js +226 -299
  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 +12 -1
  51. package/dist/live/osv-scanner.js +50 -15
  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 -136
  158. package/package.json +35 -35
  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
@@ -0,0 +1,57 @@
1
+ /**
2
+ * One severity rule for every surface (AD-046).
3
+ *
4
+ * The desktop app grades an advisory by where the vulnerable package sits in
5
+ * the dependency graph, and every 4DA surface presents that one grade. This
6
+ * module is the MCP server's copy of the rule. Its Rust twin is
7
+ * `osv::identity::scope_adjusted_urgency` (src-tauri/src/osv/identity.rs):
8
+ * change one, change both, or the MCP server and the app disagree about the
9
+ * same advisory again.
10
+ *
11
+ * Measured 2026-09-10: `vulnerability_scan` graded `sandbox@3.1.2` (a
12
+ * transitive of paddle-webhook, dev/runtime scope unknown) CRITICAL
13
+ * (`by_severity.critical: 1`, and the briefing read "CRITICAL: Sandbox
14
+ * Breakout") while the app graded the same advisory High.
15
+ *
16
+ * The rule, applied in this order:
17
+ * (a) transitive-only clamps critical to high;
18
+ * (b) dev-only drops ONE level: critical to high, high to medium, medium to
19
+ * low; low stays low.
20
+ * `unknown` stays `unknown` through both steps. Unknown dev scope is not dev:
21
+ * no discount without evidence. The advisory's own tier is never discarded;
22
+ * surfaces carry it beside the presented grade as `advisory_severity`.
23
+ */
24
+ import type { VulnerabilityEntry } from "./types.js";
25
+ export type SeverityTier = VulnerabilityEntry["severity"];
26
+ export interface DependencyScope {
27
+ isDirect: boolean;
28
+ isDev: boolean;
29
+ devScopeKnown: boolean;
30
+ }
31
+ /** Ordering for sorts and "at or above" filters; unknown ranks lowest. */
32
+ export declare const SEVERITY_RANK: Record<SeverityTier, number>;
33
+ /**
34
+ * THE rule. Twin of the Rust `osv::identity::scope_adjusted_urgency`.
35
+ *
36
+ * direct runtime critical -> critical; transitive runtime critical -> high;
37
+ * direct dev critical -> high; transitive dev critical -> medium;
38
+ * direct dev high -> medium; transitive dev high -> medium;
39
+ * transitive runtime high -> high.
40
+ */
41
+ export declare function scopeAdjustedSeverity(advisory: SeverityTier, scope: DependencyScope): SeverityTier;
42
+ type ScopedEntry = Pick<VulnerabilityEntry, "severity" | "isDirect" | "isDev" | "devScopeKnown">;
43
+ /**
44
+ * The grade a surface presents for one scan entry. A missing `isDirect`
45
+ * (never produced today; defensive for foreign cache rows) is treated as
46
+ * direct, and a missing `devScopeKnown` as unknown: both mean no discount.
47
+ */
48
+ export declare function presentedSeverity(v: ScopedEntry): SeverityTier;
49
+ /**
50
+ * Why the presented grade differs from the advisory's own tier, in words, or
51
+ * null when it does not differ.
52
+ */
53
+ export declare function scopeAdjustmentReason(v: ScopedEntry): string | null;
54
+ export type SeverityCounts = Record<SeverityTier, number>;
55
+ /** Count entries per tier, using whichever tier function the caller names. */
56
+ export declare function countBySeverity<T>(entries: T[], tierOf: (entry: T) => SeverityTier): SeverityCounts;
57
+ export {};
@@ -0,0 +1,89 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * One severity rule for every surface (AD-046).
4
+ *
5
+ * The desktop app grades an advisory by where the vulnerable package sits in
6
+ * the dependency graph, and every 4DA surface presents that one grade. This
7
+ * module is the MCP server's copy of the rule. Its Rust twin is
8
+ * `osv::identity::scope_adjusted_urgency` (src-tauri/src/osv/identity.rs):
9
+ * change one, change both, or the MCP server and the app disagree about the
10
+ * same advisory again.
11
+ *
12
+ * Measured 2026-09-10: `vulnerability_scan` graded `sandbox@3.1.2` (a
13
+ * transitive of paddle-webhook, dev/runtime scope unknown) CRITICAL
14
+ * (`by_severity.critical: 1`, and the briefing read "CRITICAL: Sandbox
15
+ * Breakout") while the app graded the same advisory High.
16
+ *
17
+ * The rule, applied in this order:
18
+ * (a) transitive-only clamps critical to high;
19
+ * (b) dev-only drops ONE level: critical to high, high to medium, medium to
20
+ * low; low stays low.
21
+ * `unknown` stays `unknown` through both steps. Unknown dev scope is not dev:
22
+ * no discount without evidence. The advisory's own tier is never discarded;
23
+ * surfaces carry it beside the presented grade as `advisory_severity`.
24
+ */
25
+ const ONE_LEVEL_DOWN = {
26
+ critical: "high",
27
+ high: "medium",
28
+ medium: "low",
29
+ low: "low",
30
+ unknown: "unknown",
31
+ };
32
+ /** Ordering for sorts and "at or above" filters; unknown ranks lowest. */
33
+ export const SEVERITY_RANK = {
34
+ critical: 4,
35
+ high: 3,
36
+ medium: 2,
37
+ low: 1,
38
+ unknown: 0,
39
+ };
40
+ /**
41
+ * THE rule. Twin of the Rust `osv::identity::scope_adjusted_urgency`.
42
+ *
43
+ * direct runtime critical -> critical; transitive runtime critical -> high;
44
+ * direct dev critical -> high; transitive dev critical -> medium;
45
+ * direct dev high -> medium; transitive dev high -> medium;
46
+ * transitive runtime high -> high.
47
+ */
48
+ export function scopeAdjustedSeverity(advisory, scope) {
49
+ let tier = advisory;
50
+ if (!scope.isDirect && tier === "critical")
51
+ tier = "high";
52
+ if (scope.devScopeKnown && scope.isDev)
53
+ tier = ONE_LEVEL_DOWN[tier];
54
+ return tier;
55
+ }
56
+ /**
57
+ * The grade a surface presents for one scan entry. A missing `isDirect`
58
+ * (never produced today; defensive for foreign cache rows) is treated as
59
+ * direct, and a missing `devScopeKnown` as unknown: both mean no discount.
60
+ */
61
+ export function presentedSeverity(v) {
62
+ return scopeAdjustedSeverity(v.severity, {
63
+ isDirect: v.isDirect !== false,
64
+ isDev: v.isDev === true,
65
+ devScopeKnown: v.devScopeKnown === true,
66
+ });
67
+ }
68
+ /**
69
+ * Why the presented grade differs from the advisory's own tier, in words, or
70
+ * null when it does not differ.
71
+ */
72
+ export function scopeAdjustmentReason(v) {
73
+ if (presentedSeverity(v) === v.severity)
74
+ return null;
75
+ const transitive = v.isDirect === false;
76
+ const dev = v.devScopeKnown === true && v.isDev === true;
77
+ if (transitive && dev)
78
+ return "transitive, dev-only dependency";
79
+ if (dev)
80
+ return "dev-only dependency";
81
+ return "transitive-only dependency";
82
+ }
83
+ /** Count entries per tier, using whichever tier function the caller names. */
84
+ export function countBySeverity(entries, tierOf) {
85
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
86
+ for (const entry of entries)
87
+ counts[tierOf(entry)]++;
88
+ return counts;
89
+ }
@@ -1,4 +1,6 @@
1
1
  export type OsvEcosystem = "npm" | "crates.io" | "PyPI" | "Go" | "Maven" | "NuGet" | "RubyGems" | "Packagist" | "Pub";
2
+ /** The reinstall command that brings node_modules back in line with a directory's lockfile. */
3
+ export type InstallFixCommand = "pnpm install" | "npm ci" | "yarn install" | "bun install";
2
4
  export interface ResolvedDependency {
3
5
  name: string;
4
6
  version: string | null;
@@ -10,6 +12,56 @@ export interface ResolvedDependency {
10
12
  target: string | null;
11
13
  /** False when `target` is not active on the host platform (advisory is not relevant). */
12
14
  platformActive: boolean;
15
+ /**
16
+ * Manifest directories this exact (ecosystem, name, version) resolved from.
17
+ *
18
+ * A scan root can span several independent workspaces — `src-tauri/`,
19
+ * `relay/`, `victauri-gauntlet/` all sit under one repo and pin their own
20
+ * versions. Without this, dedupe collapsed them and the report named a
21
+ * vulnerable version with no way to say WHICH workspace pinned it, so a
22
+ * patched primary crate looked vulnerable because a sibling lagged.
23
+ */
24
+ sourceDirs: string[];
25
+ /**
26
+ * npm DIRECT deps resolved from a lockfile only: the version node_modules
27
+ * actually holds (walking up for hoisted workspaces), or null when it is not
28
+ * installed or unreadable. Undefined when the check did not run (another
29
+ * ecosystem, no node_modules, or versions read from a manifest).
30
+ */
31
+ installedVersion?: string | null;
32
+ /**
33
+ * Set on the extra audit entry that asks OSV about an INSTALLED version the
34
+ * lockfile does not pin: the lockfile's version of the same package. The
35
+ * entry exists so `vulnerable_installed` is OSV's answer, not a guess.
36
+ */
37
+ installDriftOf?: string;
38
+ /** On drift entries: the command that reinstalls from the lockfile. */
39
+ installFix?: InstallFixCommand;
40
+ }
41
+ /**
42
+ * One npm direct dependency whose installed copy differs from its lockfile.
43
+ * `dir` is the manifest directory, spelled as the resolution group spells it.
44
+ */
45
+ export interface InstallDriftRecord {
46
+ package: string;
47
+ dir: string;
48
+ lockfileVersion: string;
49
+ installedVersion: string;
50
+ fix: InstallFixCommand;
51
+ isDev: boolean;
52
+ }
53
+ /** A file one resolution group read its versions from. */
54
+ export interface ResolutionSourceRecord {
55
+ /** Absolute path. */
56
+ path: string;
57
+ kind: "lockfile" | "manifest";
58
+ /** Modification time when it was read, or null if it vanished since. */
59
+ mtimeMs: number | null;
60
+ }
61
+ /** When the dependency set in use was assembled, and from which files. */
62
+ export interface ResolutionProvenance {
63
+ resolvedAt: string;
64
+ sources: ResolutionSourceRecord[];
13
65
  }
14
66
  /**
15
67
  * An OSV record. The `/v1/querybatch` endpoint returns only `{ id, modified }`
@@ -71,6 +123,19 @@ export interface VulnerabilityEntry {
71
123
  target: string | null;
72
124
  /** False when the affected dep is not active on the host platform. */
73
125
  platformActive: boolean;
126
+ /**
127
+ * Manifest directories that pin this vulnerable version. Answers the first
128
+ * question a reader asks — *which project do I go fix?* — which a flat
129
+ * package-name list cannot.
130
+ */
131
+ sourceDirs: string[];
132
+ /**
133
+ * Set when this row is about an INSTALLED version the lockfile does not pin
134
+ * (node_modules drift): the lockfile's version of the same package.
135
+ */
136
+ installDriftOf?: string;
137
+ /** On drift rows: the command that reinstalls from the lockfile. */
138
+ installFix?: InstallFixCommand;
74
139
  }
75
140
  export interface VulnerabilityScanResult {
76
141
  scannedAt: string;
@@ -126,6 +191,12 @@ export interface RegistryPackageInfo {
126
191
  weeklyDownloads: number | null;
127
192
  isDev: boolean;
128
193
  fetchError: string | null;
194
+ /**
195
+ * The version node_modules actually holds, present only when it differs
196
+ * from `currentVersion` (the lockfile's). Several differing installs across
197
+ * workspaces are listed comma-separated.
198
+ */
199
+ installedVersion?: string;
129
200
  }
130
201
  export interface SemverDistance {
131
202
  major: number;
@@ -140,7 +211,19 @@ export interface DependencyHealthResult {
140
211
  totalDeps: number;
141
212
  outdatedCount: number;
142
213
  deprecatedCount: number;
214
+ /**
215
+ * Distinct packages with an advisory that is built on this host and is not a
216
+ * maintenance notice — the same set `vulnerability_scan` reports as
217
+ * `total_vulnerable_packages`, so the two tools agree over one scan.
218
+ */
143
219
  vulnerableCount: number;
220
+ /**
221
+ * Every advisory row the scan returned, before the platform and maintenance
222
+ * splits and before alias collapsing. Present so nothing is hidden by the
223
+ * `vulnerableCount` filter: the difference is platform-inactive packages and
224
+ * unmaintained-dependency notices.
225
+ */
226
+ advisoryCount: number;
144
227
  healthScore: number;
145
228
  dependencies: RegistryPackageInfo[];
146
229
  vulnerabilitySummary: {
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Network sources for `upgrade_impact`: registry version index, the target
3
+ * version's changelog (read from its registry archive) and OSV advisories.
4
+ *
5
+ * Privacy rule (Decision 1, option A): the only hosts contacted are the
6
+ * package's own registry (registry.npmjs.org; crates.io + static.crates.io)
7
+ * and api.osv.dev. A changelog is never fetched from GitHub or a docs site,
8
+ * even when the archive ships none — the report then hands the agent a
9
+ * `release_notes_url` built from registry metadata, and the agent decides
10
+ * whether to fetch it. Every request goes through `UpgradeNet`, which records
11
+ * the host so `_meta.sources` is a fact, not a claim, and which tests replace
12
+ * with a fake fetch.
13
+ *
14
+ * Request budget per call: index (1) + archive (1) + OSV for from/to (2) = 4,
15
+ * each cached (index 6 h, parsed changelog 7 d, OSV 1 h). The npm packument
16
+ * is large (vite's is ~39 MB, measured 2026-10-02), so only a reduced index
17
+ * is cached — never the raw document.
18
+ */
19
+ import { type FetchFn } from "./package-archive.js";
20
+ import { type ChangelogSection } from "./changelog.js";
21
+ export type UpgradeEcosystem = "npm" | "crates.io";
22
+ /** crates.io refuses API requests without a descriptive User-Agent. */
23
+ export declare const CRATES_USER_AGENT = "4da-mcp-server (https://4da.ai; upgrade_impact)";
24
+ export interface PublishedVersion {
25
+ version: string;
26
+ published: string | null;
27
+ deprecated?: string;
28
+ yanked?: boolean;
29
+ /** Archive URL (npm `dist.tarball`); crates archives are derived from name + version. */
30
+ tarball?: string;
31
+ }
32
+ export interface RegistryIndex {
33
+ /** The registry's canonical spelling (crates.io folds `-`/`_`). */
34
+ name: string;
35
+ ecosystem: UpgradeEcosystem;
36
+ versions: PublishedVersion[];
37
+ repository: string | null;
38
+ }
39
+ export interface ChangelogResult {
40
+ found: boolean;
41
+ file?: string;
42
+ sections?: ChangelogSection[];
43
+ reason?: string;
44
+ }
45
+ /** The cache surface used here; `LiveCache` satisfies it. */
46
+ export interface UpgradeCache {
47
+ get<T>(key: string): T | null;
48
+ set(key: string, data: unknown, source: string, ttlSeconds: number): void;
49
+ }
50
+ /** Network + cache seam. `contacted` collects every host actually requested. */
51
+ export interface UpgradeNet {
52
+ fetch: FetchFn;
53
+ cache: UpgradeCache | null;
54
+ contacted: Set<string>;
55
+ }
56
+ export declare function createUpgradeNet(cache: UpgradeCache | null, fetchFn?: FetchFn): UpgradeNet;
57
+ export declare function isValidPackageName(name: string, ecosystem: UpgradeEcosystem): boolean;
58
+ /** Registry index with versions in ascending precedence order (unreadable versions dropped). Null = unknown package. */
59
+ export declare function getRegistryIndex(net: UpgradeNet, ecosystem: UpgradeEcosystem, name: string): Promise<RegistryIndex | null>;
60
+ /** The registry archive URL for one version, or null when it is not on the package's own registry. */
61
+ export declare function archiveUrl(index: RegistryIndex, version: PublishedVersion): string | null;
62
+ /** Parsed changelog shipped inside one version's archive (cached 7 days per version). */
63
+ export declare function getChangelog(net: UpgradeNet, index: RegistryIndex, version: PublishedVersion): Promise<ChangelogResult>;
64
+ /** OSV advisory ids affecting one version, or null when OSV could not be reached. */
65
+ export declare function getOsvAdvisories(net: UpgradeNet, ecosystem: UpgradeEcosystem, name: string, version: string): Promise<string[] | null>;
66
+ /**
67
+ * Release-notes page derived from the registry's own `repository` field: a
68
+ * GitHub repo becomes `https://github.com/<owner>/<repo>/releases`; any other
69
+ * https repository URL is returned as-is; anything else is null.
70
+ */
71
+ export declare function releaseNotesUrl(repository: string | null): string | null;
@@ -0,0 +1,198 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Network sources for `upgrade_impact`: registry version index, the target
4
+ * version's changelog (read from its registry archive) and OSV advisories.
5
+ *
6
+ * Privacy rule (Decision 1, option A): the only hosts contacted are the
7
+ * package's own registry (registry.npmjs.org; crates.io + static.crates.io)
8
+ * and api.osv.dev. A changelog is never fetched from GitHub or a docs site,
9
+ * even when the archive ships none — the report then hands the agent a
10
+ * `release_notes_url` built from registry metadata, and the agent decides
11
+ * whether to fetch it. Every request goes through `UpgradeNet`, which records
12
+ * the host so `_meta.sources` is a fact, not a claim, and which tests replace
13
+ * with a fake fetch.
14
+ *
15
+ * Request budget per call: index (1) + archive (1) + OSV for from/to (2) = 4,
16
+ * each cached (index 6 h, parsed changelog 7 d, OSV 1 h). The npm packument
17
+ * is large (vite's is ~39 MB, measured 2026-10-02), so only a reduced index
18
+ * is cached — never the raw document.
19
+ */
20
+ import { compareVersionPrecedence, parseSemverPrecedence } from "./semver-precedence.js";
21
+ import { ArchiveError, defaultFetch, fetchArchiveRootFiles } from "./package-archive.js";
22
+ import { findChangelogFile, isChangelogName, parseChangelog } from "./changelog.js";
23
+ /** crates.io refuses API requests without a descriptive User-Agent. */
24
+ export const CRATES_USER_AGENT = "4da-mcp-server (https://4da.ai; upgrade_impact)";
25
+ const INDEX_TTL = 6 * 3600;
26
+ const CHANGELOG_TTL = 7 * 86_400;
27
+ const OSV_TTL = 3600;
28
+ const CACHE_SOURCE = "upgrade-impact";
29
+ const INDEX_TIMEOUT_MS = 30_000;
30
+ const OSV_TIMEOUT_MS = 10_000;
31
+ export function createUpgradeNet(cache, fetchFn = defaultFetch) {
32
+ const contacted = new Set();
33
+ return {
34
+ contacted,
35
+ cache,
36
+ fetch: (url, init, timeoutMs) => {
37
+ contacted.add(new URL(url).host);
38
+ return fetchFn(url, init, timeoutMs);
39
+ },
40
+ };
41
+ }
42
+ const NPM_NAME = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
43
+ const CRATE_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
44
+ export function isValidPackageName(name, ecosystem) {
45
+ return ecosystem === "npm" ? NPM_NAME.test(name) && name.length <= 214 : CRATE_NAME.test(name);
46
+ }
47
+ async function cached(net, key, ttl, load) {
48
+ const hit = net.cache?.get(key);
49
+ if (hit !== null && hit !== undefined)
50
+ return hit;
51
+ const value = await load();
52
+ net.cache?.set(key, value, CACHE_SOURCE, ttl);
53
+ return value;
54
+ }
55
+ function repositoryUrl(repo) {
56
+ if (typeof repo === "string")
57
+ return repo;
58
+ if (repo && typeof repo === "object" && typeof repo.url === "string") {
59
+ return repo.url;
60
+ }
61
+ return null;
62
+ }
63
+ async function loadNpmIndex(net, name) {
64
+ const url = `https://registry.npmjs.org/${name.replaceAll("/", "%2F")}`;
65
+ const res = await net.fetch(url, { headers: { Accept: "application/json" } }, INDEX_TIMEOUT_MS);
66
+ if (res.status === 404)
67
+ return null;
68
+ if (!res.ok)
69
+ throw new Error(`npm registry HTTP ${res.status} for ${name}`);
70
+ const doc = (await res.json());
71
+ const versions = Object.entries(doc.versions ?? {}).map(([version, meta]) => {
72
+ const entry = { version, published: doc.time?.[version] ?? null };
73
+ if (typeof meta.deprecated === "string" && meta.deprecated)
74
+ entry.deprecated = meta.deprecated;
75
+ if (meta.dist?.tarball)
76
+ entry.tarball = meta.dist.tarball;
77
+ return entry;
78
+ });
79
+ return { name: doc.name ?? name, ecosystem: "npm", versions, repository: repositoryUrl(doc.repository) };
80
+ }
81
+ async function loadCratesIndex(net, name) {
82
+ const url = `https://crates.io/api/v1/crates/${encodeURIComponent(name)}`;
83
+ const headers = { Accept: "application/json", "User-Agent": CRATES_USER_AGENT };
84
+ const res = await net.fetch(url, { headers }, INDEX_TIMEOUT_MS);
85
+ if (res.status === 404)
86
+ return null;
87
+ if (!res.ok)
88
+ throw new Error(`crates.io HTTP ${res.status} for ${name}`);
89
+ const doc = (await res.json());
90
+ const versions = (doc.versions ?? [])
91
+ .filter((v) => typeof v.num === "string")
92
+ .map((v) => {
93
+ const entry = { version: v.num, published: v.created_at ?? null };
94
+ if (v.yanked)
95
+ entry.yanked = true;
96
+ return entry;
97
+ });
98
+ return { name: doc.crate?.name ?? name, ecosystem: "crates.io", versions, repository: doc.crate?.repository ?? null };
99
+ }
100
+ /** Registry index with versions in ascending precedence order (unreadable versions dropped). Null = unknown package. */
101
+ export async function getRegistryIndex(net, ecosystem, name) {
102
+ const key = `upgrade-impact:index:${ecosystem}:${ecosystem === "crates.io" ? name.replace(/-/g, "_") : name}`;
103
+ const index = await cached(net, key, INDEX_TTL, () => ecosystem === "npm" ? loadNpmIndex(net, name) : loadCratesIndex(net, name));
104
+ if (!index)
105
+ return null;
106
+ const versions = index.versions
107
+ .filter((v) => parseSemverPrecedence(v.version) !== null)
108
+ .sort((a, b) => compareVersionPrecedence(a.version, b.version) ?? 0);
109
+ return { ...index, versions };
110
+ }
111
+ const ARCHIVE_HOSTS = new Set(["registry.npmjs.org", "static.crates.io"]);
112
+ /** The registry archive URL for one version, or null when it is not on the package's own registry. */
113
+ export function archiveUrl(index, version) {
114
+ const url = index.ecosystem === "crates.io"
115
+ ? `https://static.crates.io/crates/${index.name}/${index.name}-${version.version}.crate`
116
+ : version.tarball;
117
+ if (!url)
118
+ return null;
119
+ try {
120
+ const parsed = new URL(url);
121
+ return parsed.protocol === "https:" && ARCHIVE_HOSTS.has(parsed.host) ? url : null;
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ }
127
+ /** Parsed changelog shipped inside one version's archive (cached 7 days per version). */
128
+ export async function getChangelog(net, index, version) {
129
+ const url = archiveUrl(index, version);
130
+ if (!url) {
131
+ return { found: false, reason: "the version's archive is not hosted on the package's own registry" };
132
+ }
133
+ const key = `upgrade-impact:changelog:${index.ecosystem}:${index.name}:${version.version}`;
134
+ const hit = net.cache?.get(key);
135
+ if (hit)
136
+ return hit;
137
+ const headers = index.ecosystem === "crates.io" ? { "User-Agent": CRATES_USER_AGENT } : {};
138
+ let files;
139
+ try {
140
+ files = await fetchArchiveRootFiles(url, isChangelogName, net.fetch, headers);
141
+ }
142
+ catch (err) {
143
+ const result = { found: false, reason: `could not read the ${version.version} archive: ${err.message}` };
144
+ // A published archive never changes, so a cap or format refusal is permanent;
145
+ // a network failure is not, and must not be remembered for a week.
146
+ if (err instanceof ArchiveError)
147
+ net.cache?.set(key, result, CACHE_SOURCE, CHANGELOG_TTL);
148
+ return result;
149
+ }
150
+ const file = findChangelogFile(files.keys());
151
+ const sections = file ? parseChangelog(files.get(file) ?? "") : [];
152
+ const result = !file
153
+ ? { found: false, reason: `the ${version.version} archive ships no changelog file` }
154
+ : sections.length === 0
155
+ ? { found: false, file, reason: `${file} has no version headings this parser recognises` }
156
+ : { found: true, file, sections };
157
+ net.cache?.set(key, result, CACHE_SOURCE, CHANGELOG_TTL);
158
+ return result;
159
+ }
160
+ /** OSV advisory ids affecting one version, or null when OSV could not be reached. */
161
+ export async function getOsvAdvisories(net, ecosystem, name, version) {
162
+ const key = `upgrade-impact:osv:${ecosystem}:${name}:${version}`;
163
+ const hit = net.cache?.get(key);
164
+ if (hit)
165
+ return hit;
166
+ try {
167
+ const res = await net.fetch("https://api.osv.dev/v1/query", {
168
+ method: "POST",
169
+ headers: { "Content-Type": "application/json" },
170
+ body: JSON.stringify({ package: { name, ecosystem }, version }),
171
+ }, OSV_TIMEOUT_MS);
172
+ if (!res.ok)
173
+ return null;
174
+ const body = (await res.json());
175
+ const ids = (body.vulns ?? []).map((v) => v.id).filter((id) => typeof id === "string");
176
+ net.cache?.set(key, ids, CACHE_SOURCE, OSV_TTL);
177
+ return ids;
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ }
183
+ /**
184
+ * Release-notes page derived from the registry's own `repository` field: a
185
+ * GitHub repo becomes `https://github.com/<owner>/<repo>/releases`; any other
186
+ * https repository URL is returned as-is; anything else is null.
187
+ */
188
+ export function releaseNotesUrl(repository) {
189
+ if (!repository)
190
+ return null;
191
+ const raw = repository.trim();
192
+ const shorthand = /^(?:github:)?([\w.-]+)\/([\w.-]+)$/.exec(raw);
193
+ const gh = shorthand ?? /github\.com[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?(?:[/#?].*)?$/i.exec(raw);
194
+ if (gh)
195
+ return `https://github.com/${gh[1]}/${gh[2].replace(/\.git$/, "")}/releases`;
196
+ const https = raw.replace(/^git\+/, "");
197
+ return /^https:\/\//i.test(https) ? https.replace(/\.git$/, "") : null;
198
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Version ordering per ecosystem, for advisory ranges and fix selection.
3
+ *
4
+ * `compareSemver` reads MAJOR.MINOR.PATCH and calls anything else "equal".
5
+ * That is wrong outside npm and crates.io, and it was wrong in practice
6
+ * (measured 2026-10-02 against pip-audit and osv-scanner):
7
+ * - PyPI versions are PEP 440, not semver. `5.1` and `4.2` are ordinary
8
+ * releases; read as "equal" to everything, django 3.2.0 was sent to the 5.1
9
+ * line's fix (5.1.1) instead of its own line's (3.2.25 / 4.2.16).
10
+ * - Go versions carry a `v`, OSV's Go ranges do not, and pseudo-versions
11
+ * (`v0.0.0-20200622213623-75b288015ac9`) order as semver prereleases.
12
+ *
13
+ * `0` is OSV's "since the beginning" bound and orders below every version.
14
+ */
15
+ import type { OsvEcosystem } from "./types.js";
16
+ interface Pep440 {
17
+ epoch: number;
18
+ release: number[];
19
+ /** Pre-release: [rank, n] with a=0, b=1, rc=2; null for none. */
20
+ pre: [number, number] | null;
21
+ post: number | null;
22
+ dev: number | null;
23
+ }
24
+ export declare function parsePep440(version: string): Pep440 | null;
25
+ export declare function comparePep440(a: Pep440, b: Pep440): number;
26
+ /** Whether `version` is readable for this ecosystem's ordering. */
27
+ export declare function isComparable(version: string, ecosystem: OsvEcosystem | string): boolean;
28
+ /**
29
+ * Order two versions of one ecosystem: 1, -1, or 0 for equal OR unreadable
30
+ * (the same contract as compareSemver, so an unreadable bound never decides).
31
+ */
32
+ export declare function compareVersions(a: string, b: string, ecosystem: OsvEcosystem | string): number;
33
+ /** Highest of `versions` in this ecosystem's order, or null when empty. */
34
+ export declare function maxVersion(versions: string[], ecosystem: OsvEcosystem | string): string | null;
35
+ /** Package-name equality as the ecosystem's registry defines it. */
36
+ export declare function samePackageName(a: string, b: string, ecosystem: OsvEcosystem | string): boolean;
37
+ /** Whether a user-typed package name (`package` parameters) names this dependency: registry rules, then case-insensitive. */
38
+ export declare function namesPackage(query: string, name: string, ecosystem: OsvEcosystem | string): boolean;
39
+ export {};