@4da/mcp-server 6.1.0 → 6.2.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 (50) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/README.md +14 -9
  3. package/dist/live/changelog-classify.d.ts +9 -3
  4. package/dist/live/changelog-classify.js +122 -12
  5. package/dist/live/changelog.d.ts +8 -4
  6. package/dist/live/changelog.js +67 -29
  7. package/dist/live/fix-paths.js +1 -1
  8. package/dist/live/github-releases.d.ts +69 -0
  9. package/dist/live/github-releases.js +252 -0
  10. package/dist/live/go-releases.d.ts +48 -0
  11. package/dist/live/go-releases.js +196 -0
  12. package/dist/live/index.d.ts +4 -5
  13. package/dist/live/index.js +7 -1
  14. package/dist/live/osv-ranges.d.ts +6 -0
  15. package/dist/live/osv-ranges.js +3 -0
  16. package/dist/live/package-archive.d.ts +2 -0
  17. package/dist/live/package-archive.js +7 -4
  18. package/dist/live/pypi-releases.d.ts +44 -0
  19. package/dist/live/pypi-releases.js +103 -0
  20. package/dist/live/release-metadata.d.ts +17 -8
  21. package/dist/live/release-metadata.js +6 -3
  22. package/dist/live/upgrade-go.d.ts +62 -0
  23. package/dist/live/upgrade-go.js +130 -0
  24. package/dist/live/upgrade-pypi.d.ts +42 -0
  25. package/dist/live/upgrade-pypi.js +85 -0
  26. package/dist/live/upgrade-sources.d.ts +28 -11
  27. package/dist/live/upgrade-sources.js +86 -19
  28. package/dist/live/zip-archive.d.ts +22 -0
  29. package/dist/live/zip-archive.js +98 -0
  30. package/dist/schema-registry.js +3 -3
  31. package/dist/server-instructions.d.ts +6 -0
  32. package/dist/server-instructions.js +20 -15
  33. package/dist/tools/dependency-check-signals.d.ts +16 -5
  34. package/dist/tools/dependency-check-signals.js +46 -18
  35. package/dist/tools/dependency-check-versions.d.ts +94 -0
  36. package/dist/tools/dependency-check-versions.js +213 -0
  37. package/dist/tools/dependency-check.d.ts +26 -10
  38. package/dist/tools/dependency-check.js +321 -74
  39. package/dist/tools/upgrade-impact-callsites.d.ts +1 -0
  40. package/dist/tools/upgrade-impact-callsites.js +23 -5
  41. package/dist/tools/upgrade-impact-changelog.d.ts +41 -0
  42. package/dist/tools/upgrade-impact-changelog.js +79 -0
  43. package/dist/tools/upgrade-impact-imports.d.ts +42 -0
  44. package/dist/tools/upgrade-impact-imports.js +260 -0
  45. package/dist/tools/upgrade-impact-report.d.ts +13 -0
  46. package/dist/tools/upgrade-impact-report.js +13 -4
  47. package/dist/tools/upgrade-impact.d.ts +13 -4
  48. package/dist/tools/upgrade-impact.js +235 -84
  49. package/dist/tools/what-should-i-know.js +1 -1
  50. package/package.json +6 -6
@@ -0,0 +1,130 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Go modules for `upgrade_impact`, from the module proxy (proxy.golang.org):
4
+ * `/@v/list` for the tagged versions, `/@v/<version>.info` for a release's
5
+ * time and VCS origin, and `/@v/<version>.zip` as the archive a changelog is
6
+ * read from (the zip holds the module's files under `<module>@<version>/`).
7
+ *
8
+ * Major versions 2 and up are DIFFERENT modules: `github.com/jackc/pgx/v5`
9
+ * is not a newer release of `github.com/jackc/pgx/v4`, and the proxy lists
10
+ * each path's own versions only. `goMajorPath` gives the path a major lives
11
+ * at, so an upgrade across majors is read from the target module and the
12
+ * report can say that every import path changes.
13
+ *
14
+ * `.info` carries `Origin` (VCS URL, the tag's ref, the module's subdirectory
15
+ * in a multi-module repository) for versions the proxy fetched recently.
16
+ * That, not a guess from the import path, names the GitHub repository and tag
17
+ * for the opt-in release-notes fallback: `go.uber.org/zap` lives at
18
+ * github.com/uber-go/zap, and aws-sdk-go-v2's `service/s3` tags its releases
19
+ * `service/s3/v1.50.0`.
20
+ */
21
+ const GO_PROXY = "https://proxy.golang.org";
22
+ const GO_TIMEOUT_MS = 15_000;
23
+ /** `.info` reads per call: one per release crossed, newest first, then dates are left null. */
24
+ const MAX_INFO_READS = 40;
25
+ /** A module path: a dotted host, then path elements (case kept; the proxy escapes capitals). */
26
+ export const GO_MODULE = /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(?:\/[A-Za-z0-9._~+-]+)+$|^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/;
27
+ /** Proxy escaping: each capital letter becomes `!` + lowercase. */
28
+ export function escapeGoPath(path) {
29
+ return path.replace(/[A-Z]/g, (c) => `!${c.toLowerCase()}`);
30
+ }
31
+ /** The major version a module path carries: `/v5` -> 5, `gopkg.in/yaml.v3` -> 3, none -> null (0 or 1). */
32
+ export function goPathMajor(path) {
33
+ const m = /\/v(\d+)$/.exec(path) ?? (/^gopkg\.in\//.test(path) ? /\.v(\d+)$/.exec(path) : null);
34
+ return m ? Number(m[1]) : null;
35
+ }
36
+ /** The module path without its major suffix. */
37
+ export function goBasePath(path) {
38
+ if (/^gopkg\.in\//.test(path))
39
+ return path.replace(/\.v\d+$/, "");
40
+ return path.replace(/\/v\d+$/, "");
41
+ }
42
+ /** The path major `major` lives at: none for 0 and 1, `/vN` (or gopkg.in's `.vN`) from 2. */
43
+ export function goMajorPath(path, major) {
44
+ const base = goBasePath(path);
45
+ if (/^gopkg\.in\//.test(path))
46
+ return `${base}.v${major}`;
47
+ return major >= 2 ? `${base}/v${major}` : base;
48
+ }
49
+ export function goVersionMajor(version) {
50
+ const m = /^v?(\d+)\./.exec(version.trim());
51
+ return m ? Number(m[1]) : null;
52
+ }
53
+ /** GitHub repository from a module path: `github.com/<owner>/<repo>/...`. */
54
+ export function githubRepoOfGoPath(path) {
55
+ const m = /^github\.com\/([\w.-]+)\/([\w.-]+)/.exec(path);
56
+ return m ? `https://github.com/${m[1]}/${m[2]}` : null;
57
+ }
58
+ export async function goInfo(net, module, version) {
59
+ const res = await net.fetch(`${GO_PROXY}/${escapeGoPath(module)}/@v/${escapeGoPath(version)}.info`, {}, GO_TIMEOUT_MS);
60
+ if (!res.ok)
61
+ return null;
62
+ try {
63
+ return (await res.json());
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
69
+ export async function loadGoIndex(net, module) {
70
+ const base = `${GO_PROXY}/${escapeGoPath(module)}/@v/list`;
71
+ const res = await net.fetch(base, {}, GO_TIMEOUT_MS);
72
+ // The proxy answers 404 and 410 for a path it cannot resolve.
73
+ if (res.status === 404 || res.status === 410)
74
+ return null;
75
+ if (!res.ok)
76
+ throw new Error(`Go module proxy HTTP ${res.status} for ${module}`);
77
+ const listed = (await res.text())
78
+ .split("\n")
79
+ .map((v) => v.trim())
80
+ .filter((v) => /^v\d+\.\d+\.\d+/.test(v));
81
+ let versions = listed.map((version) => ({ version, published: null }));
82
+ if (versions.length === 0) {
83
+ // An untagged module: the proxy's @latest is a pseudo-version.
84
+ const latest = await net.fetch(`${GO_PROXY}/${escapeGoPath(module)}/@latest`, {}, GO_TIMEOUT_MS);
85
+ if (!latest.ok)
86
+ return null;
87
+ const info = (await latest.json());
88
+ if (!info.Version)
89
+ return null;
90
+ versions = [{ version: info.Version, published: info.Time ?? null }];
91
+ }
92
+ return { name: module, ecosystem: "Go", versions, repository: githubRepoOfGoPath(module) };
93
+ }
94
+ /**
95
+ * Publish times for the versions an upgrade crosses (newest first, at most
96
+ * MAX_INFO_READS), and the target's VCS origin. Missing `.info` leaves a null date.
97
+ */
98
+ export async function enrichGoVersions(net, module, versions, target, budget = MAX_INFO_READS) {
99
+ const rest = [...versions].reverse().filter((v) => v !== target && !v.published);
100
+ const wanted = (target ? [target, ...rest] : rest).slice(0, budget);
101
+ let origin = null;
102
+ await Promise.all(wanted.map(async (v) => {
103
+ const info = await goInfo(net, module, v.version).catch(() => null);
104
+ if (info?.Time && !v.published)
105
+ v.published = info.Time;
106
+ if (v === target && info?.Origin)
107
+ origin = info.Origin;
108
+ }));
109
+ return { origin };
110
+ }
111
+ /**
112
+ * The tag prefix a module's releases carry: `service/s3` for
113
+ * `refs/tags/service/s3/v1.50.0`, "" for a module at the repository root.
114
+ * From the proxy's recorded ref when there is one; otherwise from a
115
+ * github.com path (the elements after owner/repo, minus a `/vN` suffix,
116
+ * which a major-version subdirectory does not put in its tags).
117
+ */
118
+ export function goTagPrefix(module, version, origin) {
119
+ const ref = origin?.Ref?.replace(/^refs\/tags\//, "");
120
+ if (ref && ref.endsWith(version))
121
+ return ref.slice(0, ref.length - version.length).replace(/\/$/, "");
122
+ if (origin?.Subdir !== undefined)
123
+ return origin.Subdir.replace(/(?:^|\/)v\d+$/, "");
124
+ const m = /^github\.com\/[\w.-]+\/[\w.-]+\/(.+)$/.exec(goBasePath(module));
125
+ return m ? m[1] : "";
126
+ }
127
+ /** The proxy's zip of one module version. */
128
+ export function goZipUrl(module, version) {
129
+ return `${GO_PROXY}/${escapeGoPath(module)}/@v/${escapeGoPath(version)}.zip`;
130
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * PyPI for `upgrade_impact`: the version index from the PyPI JSON API
3
+ * (`https://pypi.org/pypi/<name>/json`), each release's sdist as the archive
4
+ * its changelog is read from (files.pythonhosted.org, PyPI's own file host),
5
+ * and the links the project itself publishes in `project_urls`.
6
+ *
7
+ * Versions are PEP 440, not semver (`2.0`, `3.0.0rc1`, `1.0.post1`), so they
8
+ * are ordered with version-compare.ts's PEP 440 comparator. A release's
9
+ * publish time is its earliest file upload; it is yanked when every file is.
10
+ *
11
+ * `project_urls` is metadata, not a changelog: a "Changelog" link usually
12
+ * points at an HTML docs page this server does not parse (it is reported as
13
+ * the release-notes URL). Only a link to a raw changelog FILE on GitHub can be
14
+ * read, and only when the GitHub fallback is enabled (github-releases.ts).
15
+ */
16
+ import type { RegistryIndex, UpgradeNet } from "./upgrade-sources.js";
17
+ interface PyPIFile {
18
+ url?: string;
19
+ packagetype?: string;
20
+ filename?: string;
21
+ upload_time_iso_8601?: string;
22
+ yanked?: boolean;
23
+ yanked_reason?: string | null;
24
+ }
25
+ interface PyPIProject {
26
+ info?: {
27
+ name?: string;
28
+ home_page?: string | null;
29
+ project_urls?: Record<string, string> | null;
30
+ };
31
+ releases?: Record<string, PyPIFile[]>;
32
+ }
33
+ /** PEP 503 normalised name: lowercase, runs of `-`, `_`, `.` read as one `-`. */
34
+ export declare function normalizePypiName(name: string): string;
35
+ /** PEP 508 distribution name. */
36
+ export declare const PYPI_NAME: RegExp;
37
+ /** The project's source repository from `project_urls` / `home_page`: a GitHub URL first, then any https one. */
38
+ export declare function pypiRepository(info: PyPIProject["info"]): string | null;
39
+ /** Links the project labels as its changelog / release notes, in `project_urls` order. */
40
+ export declare function pypiChangelogLinks(info: PyPIProject["info"]): string[];
41
+ export declare function loadPypiIndex(net: UpgradeNet, name: string): Promise<RegistryIndex | null>;
42
+ export {};
@@ -0,0 +1,85 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * PyPI for `upgrade_impact`: the version index from the PyPI JSON API
4
+ * (`https://pypi.org/pypi/<name>/json`), each release's sdist as the archive
5
+ * its changelog is read from (files.pythonhosted.org, PyPI's own file host),
6
+ * and the links the project itself publishes in `project_urls`.
7
+ *
8
+ * Versions are PEP 440, not semver (`2.0`, `3.0.0rc1`, `1.0.post1`), so they
9
+ * are ordered with version-compare.ts's PEP 440 comparator. A release's
10
+ * publish time is its earliest file upload; it is yanked when every file is.
11
+ *
12
+ * `project_urls` is metadata, not a changelog: a "Changelog" link usually
13
+ * points at an HTML docs page this server does not parse (it is reported as
14
+ * the release-notes URL). Only a link to a raw changelog FILE on GitHub can be
15
+ * read, and only when the GitHub fallback is enabled (github-releases.ts).
16
+ */
17
+ import { parsePep440 } from "./version-compare.js";
18
+ const PYPI_TIMEOUT_MS = 30_000;
19
+ /** PEP 503 normalised name: lowercase, runs of `-`, `_`, `.` read as one `-`. */
20
+ export function normalizePypiName(name) {
21
+ return name.toLowerCase().replace(/[-_.]+/g, "-");
22
+ }
23
+ /** PEP 508 distribution name. */
24
+ export const PYPI_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,198}[A-Za-z0-9])?$/;
25
+ /** The sdist to read a changelog from: a `.tar.gz` first, then a `.zip`. */
26
+ function pickSdist(files) {
27
+ const sdists = files.filter((f) => f.packagetype === "sdist" && typeof f.url === "string");
28
+ return (sdists.find((f) => /\.tar\.gz$/i.test(f.url)) ?? sdists.find((f) => /\.zip$/i.test(f.url)))?.url;
29
+ }
30
+ const REPO_KEY = /^(?:source(?: code)?|repository|repo|code|github|git|homepage|home|project)$/i;
31
+ const CHANGELOG_KEY = /change ?log|changes|release ?notes|releases|history|news|what'?s ?new/i;
32
+ /** The project's source repository from `project_urls` / `home_page`: a GitHub URL first, then any https one. */
33
+ export function pypiRepository(info) {
34
+ const urls = Object.entries(info?.project_urls ?? {}).filter(([, v]) => typeof v === "string");
35
+ const github = (v) => /^https?:\/\/(?:www\.)?github\.com\/[\w.-]+\/[\w.-]+/i.test(v);
36
+ const keyed = urls.filter(([k]) => REPO_KEY.test(k.trim()));
37
+ const pick = keyed.find(([, v]) => github(v))?.[1] ??
38
+ urls.find(([, v]) => github(v))?.[1] ??
39
+ (info?.home_page && github(info.home_page) ? info.home_page : null) ??
40
+ keyed.find(([, v]) => /^https:\/\//i.test(v))?.[1] ??
41
+ null;
42
+ return pick;
43
+ }
44
+ /** Links the project labels as its changelog / release notes, in `project_urls` order. */
45
+ export function pypiChangelogLinks(info) {
46
+ return Object.entries(info?.project_urls ?? {})
47
+ .filter(([k, v]) => typeof v === "string" && CHANGELOG_KEY.test(k) && /^https:\/\//i.test(v))
48
+ .map(([, v]) => v);
49
+ }
50
+ export async function loadPypiIndex(net, name) {
51
+ const url = `https://pypi.org/pypi/${encodeURIComponent(normalizePypiName(name))}/json`;
52
+ const res = await net.fetch(url, { headers: { Accept: "application/json" } }, PYPI_TIMEOUT_MS);
53
+ if (res.status === 404)
54
+ return null;
55
+ if (!res.ok)
56
+ throw new Error(`PyPI HTTP ${res.status} for ${name}`);
57
+ const doc = (await res.json());
58
+ if (!doc.releases || typeof doc.releases !== "object")
59
+ throw new Error(`PyPI returned no release list for ${name}`);
60
+ const versions = [];
61
+ for (const [version, files] of Object.entries(doc.releases)) {
62
+ if (!parsePep440(version))
63
+ continue;
64
+ const list = Array.isArray(files) ? files : [];
65
+ const times = list.map((f) => f.upload_time_iso_8601).filter((t) => typeof t === "string").sort();
66
+ const entry = { version, published: times[0] ?? null };
67
+ if (list.length > 0 && list.every((f) => f.yanked)) {
68
+ entry.yanked = true;
69
+ const reason = list.find((f) => f.yanked_reason)?.yanked_reason;
70
+ if (reason)
71
+ entry.deprecated = `yanked: ${reason}`;
72
+ }
73
+ const sdist = pickSdist(list);
74
+ if (sdist)
75
+ entry.tarball = sdist;
76
+ versions.push(entry);
77
+ }
78
+ return {
79
+ name: doc.info?.name ?? name,
80
+ ecosystem: "PyPI",
81
+ versions,
82
+ repository: pypiRepository(doc.info),
83
+ changelogLinks: pypiChangelogLinks(doc.info),
84
+ };
85
+ }
@@ -2,14 +2,16 @@
2
2
  * Network sources for `upgrade_impact`: registry version index, the target
3
3
  * version's changelog (read from its registry archive) and OSV advisories.
4
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.
5
+ * Privacy rule (Decision 1, option A): by default the only hosts contacted
6
+ * are the package's own registry (registry.npmjs.org; crates.io +
7
+ * static.crates.io; pypi.org + files.pythonhosted.org; proxy.golang.org) and
8
+ * api.osv.dev. A changelog is read from GitHub only when the user opts in
9
+ * (Decision 1, option B: FOURDA_GITHUB_CHANGELOG, github-releases.ts) and the
10
+ * archive has none for the range; otherwise the report hands the agent a
11
+ * `release_notes_url` built from registry metadata and says how to enable the
12
+ * fallback. Every request goes through `UpgradeNet`, which records the host
13
+ * so `_meta.sources` is a fact, not a claim, and which tests replace with a
14
+ * fake fetch.
13
15
  *
14
16
  * Request budget per call: index (1) + archive (1) + OSV for from/to (2) = 4,
15
17
  * each cached (index 6 h, parsed changelog 7 d, OSV 1 h). The npm packument
@@ -17,8 +19,17 @@
17
19
  * is cached — never the raw document.
18
20
  */
19
21
  import { type FetchFn } from "./package-archive.js";
20
- import { type ChangelogSection } from "./changelog.js";
21
- export type UpgradeEcosystem = "npm" | "crates.io";
22
+ import { type ChangelogSection, type VersionOrder } from "./changelog.js";
23
+ export type UpgradeEcosystem = "npm" | "crates.io" | "PyPI" | "Go";
24
+ export declare const UPGRADE_ECOSYSTEMS: readonly UpgradeEcosystem[];
25
+ /** Version order for an ecosystem: semver precedence (npm, crates.io, Go without its `v`), PEP 440 (PyPI). Null = unreadable. */
26
+ export declare function versionOrder(ecosystem: UpgradeEcosystem): VersionOrder;
27
+ /** Readable as a version in this ecosystem. */
28
+ export declare function isReadableVersion(version: string, ecosystem: UpgradeEcosystem): boolean;
29
+ /** A prerelease in this ecosystem's own terms (PEP 440 `a`/`b`/`rc`/`.dev`; semver `-x`; a Go pseudo-version). */
30
+ export declare function isPrerelease(version: string, ecosystem: UpgradeEcosystem): boolean;
31
+ /** Package-name identity: crates.io folds `-`/`_`, PyPI normalises per PEP 503, npm and Go are exact. */
32
+ export declare function sameUpgradeName(a: string, b: string, ecosystem: string): boolean;
22
33
  /** crates.io refuses API requests without a descriptive User-Agent. */
23
34
  export declare const CRATES_USER_AGENT = "4da-mcp-server (https://4da.ai; upgrade_impact)";
24
35
  export interface PublishedVersion {
@@ -26,7 +37,7 @@ export interface PublishedVersion {
26
37
  published: string | null;
27
38
  deprecated?: string;
28
39
  yanked?: boolean;
29
- /** Archive URL (npm `dist.tarball`); crates archives are derived from name + version. */
40
+ /** Archive URL (npm `dist.tarball`, PyPI sdist); crates and Go archives are derived from name + version. */
30
41
  tarball?: string;
31
42
  }
32
43
  export interface RegistryIndex {
@@ -35,12 +46,18 @@ export interface RegistryIndex {
35
46
  ecosystem: UpgradeEcosystem;
36
47
  versions: PublishedVersion[];
37
48
  repository: string | null;
49
+ /** Links the project itself labels as its changelog (PyPI `project_urls`). */
50
+ changelogLinks?: string[];
38
51
  }
39
52
  export interface ChangelogResult {
40
53
  found: boolean;
41
54
  file?: string;
42
55
  sections?: ChangelogSection[];
43
56
  reason?: string;
57
+ /** Where the sections came from: the registry archive (default), or the opt-in GitHub fallback. */
58
+ source?: "archive" | "github-releases" | "github-file";
59
+ /** The page or API the GitHub fallback read. */
60
+ url?: string;
44
61
  }
45
62
  /** The cache surface used here; `LiveCache` satisfies it. */
46
63
  export interface UpgradeCache {
@@ -3,14 +3,16 @@
3
3
  * Network sources for `upgrade_impact`: registry version index, the target
4
4
  * version's changelog (read from its registry archive) and OSV advisories.
5
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.
6
+ * Privacy rule (Decision 1, option A): by default the only hosts contacted
7
+ * are the package's own registry (registry.npmjs.org; crates.io +
8
+ * static.crates.io; pypi.org + files.pythonhosted.org; proxy.golang.org) and
9
+ * api.osv.dev. A changelog is read from GitHub only when the user opts in
10
+ * (Decision 1, option B: FOURDA_GITHUB_CHANGELOG, github-releases.ts) and the
11
+ * archive has none for the range; otherwise the report hands the agent a
12
+ * `release_notes_url` built from registry metadata and says how to enable the
13
+ * fallback. Every request goes through `UpgradeNet`, which records the host
14
+ * so `_meta.sources` is a fact, not a claim, and which tests replace with a
15
+ * fake fetch.
14
16
  *
15
17
  * Request budget per call: index (1) + archive (1) + OSV for from/to (2) = 4,
16
18
  * each cached (index 6 h, parsed changelog 7 d, OSV 1 h). The npm packument
@@ -18,8 +20,40 @@
18
20
  * is cached — never the raw document.
19
21
  */
20
22
  import { compareVersionPrecedence, parseSemverPrecedence } from "./semver-precedence.js";
21
- import { ArchiveError, defaultFetch, fetchArchiveRootFiles } from "./package-archive.js";
23
+ import { ArchiveError, defaultFetch, fetchArchiveBytes, readRootTextFiles } from "./package-archive.js";
22
24
  import { CHANGELOG_PARSER_VERSION, findChangelogFile, isChangelogName, parseChangelog } from "./changelog.js";
25
+ import { compareVersions, isComparable } from "./version-compare.js";
26
+ import { isPrereleaseVersion } from "./osv-ranges.js";
27
+ import { looksLikeZip, readZipRootTextFiles } from "./zip-archive.js";
28
+ import { loadPypiIndex, normalizePypiName, PYPI_NAME } from "./upgrade-pypi.js";
29
+ import { GO_MODULE, goZipUrl, loadGoIndex } from "./upgrade-go.js";
30
+ export const UPGRADE_ECOSYSTEMS = ["npm", "crates.io", "PyPI", "Go"];
31
+ /** Version order for an ecosystem: semver precedence (npm, crates.io, Go without its `v`), PEP 440 (PyPI). Null = unreadable. */
32
+ export function versionOrder(ecosystem) {
33
+ if (ecosystem === "npm" || ecosystem === "crates.io")
34
+ return compareVersionPrecedence;
35
+ return (a, b) => (isComparable(a, ecosystem) && isComparable(b, ecosystem) ? compareVersions(a, b, ecosystem) : null);
36
+ }
37
+ /** Readable as a version in this ecosystem. */
38
+ export function isReadableVersion(version, ecosystem) {
39
+ if (ecosystem === "npm" || ecosystem === "crates.io")
40
+ return parseSemverPrecedence(version) !== null;
41
+ return version !== "0" && isComparable(version, ecosystem);
42
+ }
43
+ /** A prerelease in this ecosystem's own terms (PEP 440 `a`/`b`/`rc`/`.dev`; semver `-x`; a Go pseudo-version). */
44
+ export function isPrerelease(version, ecosystem) {
45
+ if (ecosystem === "npm" || ecosystem === "crates.io")
46
+ return (parseSemverPrecedence(version)?.prerelease.length ?? 0) > 0;
47
+ return isPrereleaseVersion(version, ecosystem);
48
+ }
49
+ /** Package-name identity: crates.io folds `-`/`_`, PyPI normalises per PEP 503, npm and Go are exact. */
50
+ export function sameUpgradeName(a, b, ecosystem) {
51
+ if (ecosystem === "crates.io")
52
+ return a.replace(/-/g, "_") === b.replace(/-/g, "_");
53
+ if (ecosystem === "PyPI")
54
+ return normalizePypiName(a) === normalizePypiName(b);
55
+ return a === b;
56
+ }
23
57
  /** crates.io refuses API requests without a descriptive User-Agent. */
24
58
  export const CRATES_USER_AGENT = "4da-mcp-server (https://4da.ai; upgrade_impact)";
25
59
  const INDEX_TTL = 6 * 3600;
@@ -42,7 +76,16 @@ export function createUpgradeNet(cache, fetchFn = defaultFetch) {
42
76
  const NPM_NAME = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
43
77
  const CRATE_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
44
78
  export function isValidPackageName(name, ecosystem) {
45
- return ecosystem === "npm" ? NPM_NAME.test(name) && name.length <= 214 : CRATE_NAME.test(name);
79
+ switch (ecosystem) {
80
+ case "npm":
81
+ return NPM_NAME.test(name) && name.length <= 214;
82
+ case "crates.io":
83
+ return CRATE_NAME.test(name);
84
+ case "PyPI":
85
+ return PYPI_NAME.test(name);
86
+ case "Go":
87
+ return GO_MODULE.test(name) && name.length <= 300 && !name.split("/").some((p) => p === "." || p === "..");
88
+ }
46
89
  }
47
90
  async function cached(net, key, ttl, load) {
48
91
  const hit = net.cache?.get(key);
@@ -99,21 +142,36 @@ async function loadCratesIndex(net, name) {
99
142
  }
100
143
  /** Registry index with versions in ascending precedence order (unreadable versions dropped). Null = unknown package. */
101
144
  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));
145
+ const keyName = ecosystem === "crates.io" ? name.replace(/-/g, "_") : ecosystem === "PyPI" ? normalizePypiName(name) : name;
146
+ const key = `upgrade-impact:index:${ecosystem}:${keyName}`;
147
+ const index = await cached(net, key, INDEX_TTL, () => {
148
+ switch (ecosystem) {
149
+ case "npm":
150
+ return loadNpmIndex(net, name);
151
+ case "crates.io":
152
+ return loadCratesIndex(net, name);
153
+ case "PyPI":
154
+ return loadPypiIndex(net, name);
155
+ case "Go":
156
+ return loadGoIndex(net, name);
157
+ }
158
+ });
104
159
  if (!index)
105
160
  return null;
161
+ const order = versionOrder(ecosystem);
106
162
  const versions = index.versions
107
- .filter((v) => parseSemverPrecedence(v.version) !== null)
108
- .sort((a, b) => compareVersionPrecedence(a.version, b.version) ?? 0);
163
+ .filter((v) => isReadableVersion(v.version, ecosystem))
164
+ .sort((a, b) => order(a.version, b.version) ?? 0);
109
165
  return { ...index, versions };
110
166
  }
111
- const ARCHIVE_HOSTS = new Set(["registry.npmjs.org", "static.crates.io"]);
167
+ const ARCHIVE_HOSTS = new Set(["registry.npmjs.org", "static.crates.io", "files.pythonhosted.org", "proxy.golang.org"]);
112
168
  /** The registry archive URL for one version, or null when it is not on the package's own registry. */
113
169
  export function archiveUrl(index, version) {
114
170
  const url = index.ecosystem === "crates.io"
115
171
  ? `https://static.crates.io/crates/${index.name}/${index.name}-${version.version}.crate`
116
- : version.tarball;
172
+ : index.ecosystem === "Go"
173
+ ? goZipUrl(index.name, version.version)
174
+ : version.tarball;
117
175
  if (!url)
118
176
  return null;
119
177
  try {
@@ -132,7 +190,12 @@ export function changelogCacheKey(ecosystem, name, version) {
132
190
  export async function getChangelog(net, index, version) {
133
191
  const url = archiveUrl(index, version);
134
192
  if (!url) {
135
- return { found: false, reason: "the version's archive is not hosted on the package's own registry" };
193
+ return {
194
+ found: false,
195
+ reason: index.ecosystem === "PyPI" && !version.tarball
196
+ ? `${version.version} has no source distribution on PyPI (wheels only), so there is no archive changelog to read`
197
+ : "the version's archive is not hosted on the package's own registry",
198
+ };
136
199
  }
137
200
  // The parser version is in the key: a parser fix must not wait out the 7-day TTL.
138
201
  const key = changelogCacheKey(index.ecosystem, index.name, version.version);
@@ -142,7 +205,11 @@ export async function getChangelog(net, index, version) {
142
205
  const headers = index.ecosystem === "crates.io" ? { "User-Agent": CRATES_USER_AGENT } : {};
143
206
  let files;
144
207
  try {
145
- files = await fetchArchiveRootFiles(url, isChangelogName, net.fetch, headers);
208
+ const bytes = await fetchArchiveBytes(url, net.fetch, headers);
209
+ // A Go module zip holds its files under `<module>@<version>/`; a zip sdist and every tar one directory deep.
210
+ files = looksLikeZip(bytes)
211
+ ? readZipRootTextFiles(bytes, isChangelogName, index.ecosystem === "Go" ? `${index.name}@${version.version}/` : null)
212
+ : readRootTextFiles(bytes, isChangelogName);
146
213
  }
147
214
  catch (err) {
148
215
  const result = { found: false, reason: `could not read the ${version.version} archive: ${err.message}` };
@@ -158,7 +225,7 @@ export async function getChangelog(net, index, version) {
158
225
  ? { found: false, reason: `the ${version.version} archive ships no changelog file` }
159
226
  : sections.length === 0
160
227
  ? { found: false, file, reason: `${file} has no version headings this parser recognises` }
161
- : { found: true, file, sections };
228
+ : { found: true, file, sections, source: "archive" };
162
229
  net.cache?.set(key, result, CACHE_SOURCE, CHANGELOG_TTL);
163
230
  return result;
164
231
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Zip archive reader for `upgrade_impact`, Node built-ins only.
3
+ *
4
+ * Go module zips (proxy.golang.org `/@v/<version>.zip`) and the occasional
5
+ * PyPI `.zip` sdist are zip files, not gzip tars. The job is the same as
6
+ * package-archive.ts's: pull a few root-level text files (the changelog) out
7
+ * of a third-party archive without trusting it. Only the central directory
8
+ * is read, so a file's declared sizes come from one place; stored (0) and
9
+ * deflate (8) entries are read, everything else (zip64, encryption, other
10
+ * methods) is skipped rather than guessed at. Each inflate is capped at
11
+ * MAX_FILE_BYTES, so a zip bomb stops at the cap.
12
+ */
13
+ /** True when the buffer starts like a zip archive (a local file header). */
14
+ export declare function looksLikeZip(buf: Buffer): boolean;
15
+ /**
16
+ * Text files directly under `root` (a path prefix such as
17
+ * `github.com/foo/bar@v1.2.3/`; "" for every top-level file of a one-directory
18
+ * archive) whose basename `accept` approves. When `root` is null, files one
19
+ * directory deep are read, as the tar reader does. Oversized, binary and
20
+ * unreadable entries are skipped.
21
+ */
22
+ export declare function readZipRootTextFiles(buf: Buffer, accept: (basename: string) => boolean, root?: string | null): Map<string, string>;
@@ -0,0 +1,98 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Zip archive reader for `upgrade_impact`, Node built-ins only.
4
+ *
5
+ * Go module zips (proxy.golang.org `/@v/<version>.zip`) and the occasional
6
+ * PyPI `.zip` sdist are zip files, not gzip tars. The job is the same as
7
+ * package-archive.ts's: pull a few root-level text files (the changelog) out
8
+ * of a third-party archive without trusting it. Only the central directory
9
+ * is read, so a file's declared sizes come from one place; stored (0) and
10
+ * deflate (8) entries are read, everything else (zip64, encryption, other
11
+ * methods) is skipped rather than guessed at. Each inflate is capped at
12
+ * MAX_FILE_BYTES, so a zip bomb stops at the cap.
13
+ */
14
+ import { inflateRawSync } from "node:zlib";
15
+ import { ArchiveError, MAX_COMPRESSED_BYTES, MAX_FILE_BYTES } from "./package-archive.js";
16
+ const EOCD_SIG = 0x06054b50;
17
+ const CDH_SIG = 0x02014b50;
18
+ const LFH_SIG = 0x04034b50;
19
+ /** The end-of-central-directory record is 22 bytes plus a comment of at most 65,535. */
20
+ const EOCD_SEARCH = 22 + 0xffff;
21
+ const MAX_ENTRIES = 200_000;
22
+ /** True when the buffer starts like a zip archive (a local file header). */
23
+ export function looksLikeZip(buf) {
24
+ return buf.length >= 4 && buf.readUInt32LE(0) === LFH_SIG;
25
+ }
26
+ function findEocd(buf) {
27
+ const stop = Math.max(0, buf.length - EOCD_SEARCH);
28
+ for (let i = buf.length - 22; i >= stop; i--) {
29
+ if (buf.readUInt32LE(i) === EOCD_SIG)
30
+ return i;
31
+ }
32
+ throw new ArchiveError("not a zip archive (no end-of-central-directory record)");
33
+ }
34
+ /**
35
+ * Text files directly under `root` (a path prefix such as
36
+ * `github.com/foo/bar@v1.2.3/`; "" for every top-level file of a one-directory
37
+ * archive) whose basename `accept` approves. When `root` is null, files one
38
+ * directory deep are read, as the tar reader does. Oversized, binary and
39
+ * unreadable entries are skipped.
40
+ */
41
+ export function readZipRootTextFiles(buf, accept, root = null) {
42
+ if (buf.length > MAX_COMPRESSED_BYTES) {
43
+ throw new ArchiveError(`archive is ${buf.length} bytes compressed (cap ${MAX_COMPRESSED_BYTES})`);
44
+ }
45
+ if (buf.length < 22)
46
+ throw new ArchiveError("not a zip archive (too short)");
47
+ const eocd = findEocd(buf);
48
+ const count = buf.readUInt16LE(eocd + 10);
49
+ const cdSize = buf.readUInt32LE(eocd + 12);
50
+ const cdOffset = buf.readUInt32LE(eocd + 16);
51
+ if (cdOffset === 0xffffffff || count === 0xffff)
52
+ throw new ArchiveError("zip64 archives are not read");
53
+ if (cdOffset + cdSize > buf.length || count > MAX_ENTRIES)
54
+ throw new ArchiveError("corrupt zip central directory");
55
+ const files = new Map();
56
+ let p = cdOffset;
57
+ for (let n = 0; n < count; n++) {
58
+ if (p + 46 > buf.length || buf.readUInt32LE(p) !== CDH_SIG)
59
+ throw new ArchiveError("corrupt zip central directory entry");
60
+ const flags = buf.readUInt16LE(p + 8);
61
+ const method = buf.readUInt16LE(p + 10);
62
+ const compressed = buf.readUInt32LE(p + 20);
63
+ const size = buf.readUInt32LE(p + 24);
64
+ const nameLen = buf.readUInt16LE(p + 28);
65
+ const extraLen = buf.readUInt16LE(p + 30);
66
+ const commentLen = buf.readUInt16LE(p + 32);
67
+ const local = buf.readUInt32LE(p + 42);
68
+ const name = buf.subarray(p + 46, p + 46 + nameLen).toString("utf8");
69
+ p += 46 + nameLen + extraLen + commentLen;
70
+ if (name.endsWith("/") || !isRootFile(name, root) || !accept(name.split("/").pop() ?? ""))
71
+ continue;
72
+ if (flags & 0x1 || (method !== 0 && method !== 8) || size > MAX_FILE_BYTES)
73
+ continue;
74
+ if (local + 30 > buf.length || buf.readUInt32LE(local) !== LFH_SIG)
75
+ continue;
76
+ const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
77
+ if (start + compressed > buf.length)
78
+ continue;
79
+ const raw = buf.subarray(start, start + compressed);
80
+ let data;
81
+ try {
82
+ data = method === 0 ? Buffer.from(raw) : inflateRawSync(raw, { maxOutputLength: MAX_FILE_BYTES });
83
+ }
84
+ catch {
85
+ continue;
86
+ }
87
+ if (data.includes(0))
88
+ continue;
89
+ files.set(name, data.toString("utf8"));
90
+ }
91
+ return files;
92
+ }
93
+ function isRootFile(name, root) {
94
+ if (root !== null)
95
+ return name.startsWith(root) && !name.slice(root.length).includes("/") && name.length > root.length;
96
+ const parts = name.split("/").filter(Boolean);
97
+ return parts.length > 0 && parts.length <= 2;
98
+ }
@@ -31,7 +31,7 @@ const WRITE_LOCAL = { readOnlyHint: false, openWorldHint: false, destructiveHint
31
31
  export const TOOL_REGISTRY = {
32
32
  // --- Dependency Security (standalone) ---
33
33
  vulnerability_scan: {
34
- summary: "Scan this project's lockfiles (npm/pnpm/yarn/bun, Cargo, Python, Go) for known OSV.dev vulnerabilities, transitives included, with fix versions and where each is pinned; `package` for one dep. Call when the user asks about security, vulnerabilities or CVEs, or before recommending a dependency.",
34
+ summary: "Scan this project's lockfiles (npm/pnpm/yarn/bun, Cargo, Python, Go) for OSV.dev vulnerabilities in installed versions, transitives included, with fix versions and where each is pinned; `package` for one dep. Call when asked about security or CVEs. To vet a version first: dependency_check.",
35
35
  definition: vulnerabilityScanTool,
36
36
  category: "security",
37
37
  tags: ["security", "vulnerabilities", "cve", "dependencies", "osv"],
@@ -55,7 +55,7 @@ export const TOOL_REGISTRY = {
55
55
  annotations: { title: "Upgrade planner", ...READ_NETWORK },
56
56
  },
57
57
  upgrade_impact: {
58
- summary: "What changes between the installed version of ONE dependency and a target version: changelog entries per release, breaking changes, deprecations, security fixes, and the files in this project that import it. Call before upgrading or bumping a dependency.",
58
+ summary: "What changes between the installed version of ONE dependency and a target: changelog per release, breaking changes, deprecations, security fixes, and this project's files that import it. Call before upgrading or bumping a dependency.",
59
59
  definition: upgradeImpactTool,
60
60
  category: "security",
61
61
  tags: ["upgrade", "changelog", "breaking-changes", "migration", "release-notes"],
@@ -63,7 +63,7 @@ export const TOOL_REGISTRY = {
63
63
  annotations: { title: "Upgrade impact", ...READ_NETWORK },
64
64
  },
65
65
  dependency_check: {
66
- summary: "Verdict (proceed/wait/review/avoid/unknown) with evidence for adding a dependency or bumping one to a version. Call BEFORE you add a package or apply any version bump.",
66
+ summary: "Is a dependency version safe to write? Verdict on an exact version, or safe releases in a constraint (\"7.x\", \"~=3.1\"); npm, crates.io, PyPI, Go. Call BEFORE writing any version to a manifest: add, pin, upgrade, bump, downgrade.",
67
67
  definition: dependencyCheckTool,
68
68
  category: "security",
69
69
  tags: ["dependencies", "supply-chain", "pre-install", "upgrade", "versions"],