@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,41 @@
1
+ /**
2
+ * Which changelog `upgrade_impact` reports, and what it says when there is none.
3
+ *
4
+ * Order: the changelog inside the target version's registry archive (always;
5
+ * Decision 1 option A), then, only when the user opted in with
6
+ * FOURDA_GITHUB_CHANGELOG and the archive does not cover the range, the
7
+ * GitHub fallback (a changelog file the project links, then its releases;
8
+ * Decision 1 option B). The GitHub result replaces the archive's only when it
9
+ * covers the range or has more of its releases.
10
+ *
11
+ * With the fallback off and no usable changelog, the answer says the
12
+ * changelog is unavailable and exactly how to enable the fallback, or that
13
+ * enabling it would not help (no GitHub repository). It never fills the gap
14
+ * with a guess.
15
+ */
16
+ import { type ChangelogSection, type VersionOrder } from "../live/changelog.js";
17
+ import { type ChangelogResult, type PublishedVersion, type RegistryIndex, type UpgradeNet } from "../live/upgrade-sources.js";
18
+ export interface ResolvedChangelog {
19
+ changelog: ChangelogResult;
20
+ range: {
21
+ sections: ChangelogSection[];
22
+ coversRange: boolean;
23
+ };
24
+ /** Present when the archive did not cover the range: what the fallback did, or how to turn it on. */
25
+ github_fallback?: Record<string, unknown>;
26
+ }
27
+ export interface ChangelogRequest {
28
+ net: UpgradeNet;
29
+ index: RegistryIndex;
30
+ target: PublishedVersion;
31
+ from: string;
32
+ to: string;
33
+ firstInRange: string;
34
+ order: VersionOrder;
35
+ githubEnabled: boolean;
36
+ /** Repository to read releases from (Go: the proxy's VCS origin when known). */
37
+ repository: string | null;
38
+ /** Go: the module's tag prefix in its repository. */
39
+ tagPrefix?: string | null;
40
+ }
41
+ export declare function resolveChangelog(req: ChangelogRequest): Promise<ResolvedChangelog>;
@@ -0,0 +1,79 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Which changelog `upgrade_impact` reports, and what it says when there is none.
4
+ *
5
+ * Order: the changelog inside the target version's registry archive (always;
6
+ * Decision 1 option A), then, only when the user opted in with
7
+ * FOURDA_GITHUB_CHANGELOG and the archive does not cover the range, the
8
+ * GitHub fallback (a changelog file the project links, then its releases;
9
+ * Decision 1 option B). The GitHub result replaces the archive's only when it
10
+ * covers the range or has more of its releases.
11
+ *
12
+ * With the fallback off and no usable changelog, the answer says the
13
+ * changelog is unavailable and exactly how to enable the fallback, or that
14
+ * enabling it would not help (no GitHub repository). It never fills the gap
15
+ * with a guess.
16
+ */
17
+ import { selectRange } from "../live/changelog.js";
18
+ import { GITHUB_CHANGELOG_ENV, getGithubChangelog, parseGithubRepo } from "../live/github-releases.js";
19
+ import { getChangelog } from "../live/upgrade-sources.js";
20
+ function rangeOf(result, req) {
21
+ return result.found && result.sections
22
+ ? selectRange(result.sections, req.from, req.to, req.firstInRange, req.order)
23
+ : { sections: [], coversRange: false };
24
+ }
25
+ export async function resolveChangelog(req) {
26
+ const archive = await getChangelog(req.net, req.index, req.target);
27
+ const archiveRange = rangeOf(archive, req);
28
+ if (archiveRange.coversRange)
29
+ return { changelog: archive, range: archiveRange };
30
+ const repo = parseGithubRepo(req.repository);
31
+ const pyLinks = (req.index.changelogLinks ?? []).filter((l) => /^https:\/\/(?:github\.com|raw\.githubusercontent\.com)\//i.test(l));
32
+ const archiveState = !archive.found
33
+ ? archive.reason ?? "no changelog in the archive"
34
+ : archiveRange.sections.length === 0
35
+ ? `${archive.file} has no section for these releases`
36
+ : `${archive.file} covers only part of the range`;
37
+ if (!req.githubEnabled) {
38
+ const where = repo ? `github.com/${repo.owner}/${repo.repo}` : null;
39
+ return {
40
+ changelog: archive,
41
+ range: archiveRange,
42
+ github_fallback: where || pyLinks.length > 0
43
+ ? {
44
+ enabled: false,
45
+ how_to_enable: `Set ${GITHUB_CHANGELOG_ENV}=true in this MCP server's environment to read the release notes ${where ? `on ${where}` : "the project links on GitHub"} when the archive has none. GitHub then receives the repository name and the version range only; GITHUB_TOKEN, if set, raises its rate limit.`,
46
+ }
47
+ : {
48
+ enabled: false,
49
+ note: `The package names no GitHub repository, so ${GITHUB_CHANGELOG_ENV} would not find release notes either; see release_notes_url.`,
50
+ },
51
+ };
52
+ }
53
+ const gh = await getGithubChangelog(req.net, {
54
+ pkg: req.index.name,
55
+ ecosystem: req.index.ecosystem,
56
+ repository: req.repository,
57
+ changelogLinks: req.index.changelogLinks,
58
+ from: req.from,
59
+ to: req.to,
60
+ tagPrefix: req.tagPrefix ?? null,
61
+ order: req.order,
62
+ });
63
+ if (!gh.found) {
64
+ return { changelog: archive, range: archiveRange, github_fallback: { enabled: true, used: false, reason: gh.reason } };
65
+ }
66
+ const ghRange = rangeOf(gh, req);
67
+ if (ghRange.coversRange || ghRange.sections.length > archiveRange.sections.length) {
68
+ return {
69
+ changelog: gh,
70
+ range: ghRange,
71
+ github_fallback: { enabled: true, used: true, source: gh.source, url: gh.url, archive: archiveState },
72
+ };
73
+ }
74
+ return {
75
+ changelog: archive,
76
+ range: archiveRange,
77
+ github_fallback: { enabled: true, used: false, reason: "the GitHub release notes cover no more of the range than the archive's changelog" },
78
+ };
79
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Python and Go import sites for `upgrade_impact`.
3
+ *
4
+ * Go is exact: a file imports a module by its path (`"github.com/jackc/pgx/v5"`
5
+ * or a package below it), and the identifiers it reaches through the package
6
+ * name (`pgx.Connect`) are the symbols a changelog entry can name.
7
+ *
8
+ * Python is not: a file imports a MODULE (`import yaml`), while an upgrade names
9
+ * a DISTRIBUTION (`PyYAML`), and nothing in the source links the two. The
10
+ * mapping comes, in order of trust, from:
11
+ * 1. the installed distribution's own metadata in this project's virtualenv
12
+ * (`*.dist-info/top_level.txt`, else the top-level names in `RECORD`);
13
+ * 2. a short table of well-known distributions whose import name differs
14
+ * (beautifulsoup4 -> bs4, Pillow -> PIL, PyYAML -> yaml, ...);
15
+ * 3. the distribution name itself with `-` and `.` read as `_`, which is
16
+ * right for most packages and is reported as an ASSUMPTION every time,
17
+ * so a count of zero files is never mistaken for "not used".
18
+ */
19
+ export type ImportNameSource = "installed-metadata" | "known-mapping" | "assumed";
20
+ export interface PythonImportNames {
21
+ names: string[];
22
+ source: ImportNameSource;
23
+ /** Where the installed metadata was read, relative to the project root. */
24
+ evidence?: string;
25
+ }
26
+ /** The module name(s) a distribution is imported as, and how that was established. */
27
+ export declare function pythonImportNames(root: string | null, dist: string): PythonImportNames;
28
+ /**
29
+ * Count imports of any of `names` in one Python file and collect the names it
30
+ * reaches: `from x import A, B` gives A and B; `import x` / `import x as y`
31
+ * gives the attributes used as `x.attr` / `y.attr`.
32
+ */
33
+ export declare function scanPythonSource(source: string, names: string[], symbols: Set<string>): number;
34
+ /** True when import path `imp` is module `mod` or a package inside it, not a different major (`mod/v2`). */
35
+ export declare function goImportInModule(imp: string, mod: string): boolean;
36
+ /** The package name an import path is used through: its last element, skipping a `/vN` suffix and gopkg.in's `.vN`. */
37
+ export declare function goPackageName(imp: string): string;
38
+ /**
39
+ * Count imports of a Go module (or packages inside it) in one file and collect
40
+ * the exported identifiers reached through each import's package name.
41
+ */
42
+ export declare function scanGoSource(source: string, mod: string, symbols: Set<string>): number;
@@ -0,0 +1,260 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Python and Go import sites for `upgrade_impact`.
4
+ *
5
+ * Go is exact: a file imports a module by its path (`"github.com/jackc/pgx/v5"`
6
+ * or a package below it), and the identifiers it reaches through the package
7
+ * name (`pgx.Connect`) are the symbols a changelog entry can name.
8
+ *
9
+ * Python is not: a file imports a MODULE (`import yaml`), while an upgrade names
10
+ * a DISTRIBUTION (`PyYAML`), and nothing in the source links the two. The
11
+ * mapping comes, in order of trust, from:
12
+ * 1. the installed distribution's own metadata in this project's virtualenv
13
+ * (`*.dist-info/top_level.txt`, else the top-level names in `RECORD`);
14
+ * 2. a short table of well-known distributions whose import name differs
15
+ * (beautifulsoup4 -> bs4, Pillow -> PIL, PyYAML -> yaml, ...);
16
+ * 3. the distribution name itself with `-` and `.` read as `_`, which is
17
+ * right for most packages and is reported as an ASSUMPTION every time,
18
+ * so a count of zero files is never mistaken for "not used".
19
+ */
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ import { normalizePypiName } from "../live/upgrade-pypi.js";
23
+ import { goBasePath, goPathMajor } from "../live/upgrade-go.js";
24
+ /**
25
+ * Distributions whose import name is not their normalised name. Each is the
26
+ * package's documented import (PyPI page or the project's own docs); a
27
+ * distribution that installs several top-level modules lists them all.
28
+ */
29
+ const KNOWN_IMPORT_NAMES = {
30
+ "beautifulsoup4": ["bs4"],
31
+ "pillow": ["PIL"],
32
+ "pyyaml": ["yaml"],
33
+ "scikit-learn": ["sklearn"],
34
+ "scikit-image": ["skimage"],
35
+ "opencv-python": ["cv2"],
36
+ "opencv-python-headless": ["cv2"],
37
+ "opencv-contrib-python": ["cv2"],
38
+ "opencv-contrib-python-headless": ["cv2"],
39
+ "python-dateutil": ["dateutil"],
40
+ "pyjwt": ["jwt"],
41
+ "python-dotenv": ["dotenv"],
42
+ "protobuf": ["google.protobuf"],
43
+ "attrs": ["attr", "attrs"],
44
+ "psycopg2-binary": ["psycopg2"],
45
+ "mysqlclient": ["MySQLdb"],
46
+ "pyserial": ["serial"],
47
+ "pyopenssl": ["OpenSSL"],
48
+ "pycryptodome": ["Crypto"],
49
+ "pycryptodomex": ["Cryptodome"],
50
+ "setuptools": ["setuptools", "pkg_resources"],
51
+ "python-multipart": ["multipart", "python_multipart"],
52
+ "djangorestframework": ["rest_framework"],
53
+ "django-cors-headers": ["corsheaders"],
54
+ "python-jose": ["jose"],
55
+ "ruamel.yaml": ["ruamel.yaml"],
56
+ "grpcio": ["grpc"],
57
+ "google-api-python-client": ["googleapiclient"],
58
+ "pymupdf": ["fitz", "pymupdf"],
59
+ "python-docx": ["docx"],
60
+ "python-pptx": ["pptx"],
61
+ "websocket-client": ["websocket"],
62
+ "pyzmq": ["zmq"],
63
+ "faiss-cpu": ["faiss"],
64
+ "faiss-gpu": ["faiss"],
65
+ "discord.py": ["discord"],
66
+ "py-cpuinfo": ["cpuinfo"],
67
+ "pygobject": ["gi"],
68
+ "pycairo": ["cairo"],
69
+ "pyqt5": ["PyQt5"],
70
+ "pyqt6": ["PyQt6"],
71
+ "pyside6": ["PySide6"],
72
+ "python-ldap": ["ldap"],
73
+ "pyusb": ["usb"],
74
+ "msgpack-python": ["msgpack"],
75
+ "markdown-it-py": ["markdown_it"],
76
+ "typing-extensions": ["typing_extensions"],
77
+ "google-cloud-storage": ["google.cloud.storage"],
78
+ "google-cloud-bigquery": ["google.cloud.bigquery"],
79
+ "google-cloud-pubsub": ["google.cloud.pubsub", "google.cloud.pubsub_v1"],
80
+ "apache-airflow": ["airflow"],
81
+ "pywin32": ["win32api", "win32con", "win32com", "pythoncom", "pywintypes"],
82
+ };
83
+ const VENV_DIRS = [".venv", "venv", "env", ".env", "virtualenv"];
84
+ /** site-packages directories of virtualenvs at the project root (POSIX and Windows layouts). */
85
+ function sitePackagesDirs(root) {
86
+ const out = [];
87
+ for (const v of VENV_DIRS) {
88
+ const venv = path.join(root, v);
89
+ const win = path.join(venv, "Lib", "site-packages");
90
+ if (fs.existsSync(win))
91
+ out.push(win);
92
+ const lib = path.join(venv, "lib");
93
+ let entries = [];
94
+ try {
95
+ entries = fs.readdirSync(lib);
96
+ }
97
+ catch {
98
+ continue;
99
+ }
100
+ for (const py of entries) {
101
+ const sp = path.join(lib, py, "site-packages");
102
+ if (/^python\d/.test(py) && fs.existsSync(sp))
103
+ out.push(sp);
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+ /** Top-level importable names a RECORD lists: `pkg/__init__.py` -> pkg, `mod.py` -> mod. */
109
+ function namesFromRecord(record) {
110
+ const names = new Set();
111
+ for (const line of record.split(/\r?\n/)) {
112
+ const file = line.split(",")[0]?.trim();
113
+ if (!file || file.startsWith("..") || file.startsWith("/"))
114
+ continue;
115
+ const first = file.split("/")[0];
116
+ if (/\.(?:dist-info|egg-info|data)$/.test(first) || first === "__pycache__" || /\.pth$/.test(first))
117
+ continue;
118
+ if (file.includes("/")) {
119
+ if (/^[A-Za-z_]\w*$/.test(first))
120
+ names.add(first);
121
+ }
122
+ else {
123
+ const mod = /^([A-Za-z_]\w*)(?:\.[\w-]+)?\.(?:py|pyd|so)$/.exec(first)?.[1];
124
+ if (mod)
125
+ names.add(mod);
126
+ }
127
+ }
128
+ return [...names];
129
+ }
130
+ function readInstalledNames(root, dist) {
131
+ const want = normalizePypiName(dist);
132
+ for (const sp of sitePackagesDirs(root)) {
133
+ let entries;
134
+ try {
135
+ entries = fs.readdirSync(sp);
136
+ }
137
+ catch {
138
+ continue;
139
+ }
140
+ for (const entry of entries) {
141
+ const m = /^(.+?)-[^-]+\.(?:dist-info|egg-info)$/.exec(entry);
142
+ if (!m || normalizePypiName(m[1]) !== want)
143
+ continue;
144
+ const meta = path.join(sp, entry);
145
+ const rel = path.relative(root, meta).split(path.sep).join("/");
146
+ for (const [file, read] of [
147
+ ["top_level.txt", (t) => t.split(/\r?\n/).map((l) => l.trim()).filter((l) => /^[A-Za-z_][\w.]*$/.test(l))],
148
+ ["RECORD", namesFromRecord],
149
+ ]) {
150
+ try {
151
+ const names = read(fs.readFileSync(path.join(meta, file), "utf8"));
152
+ if (names.length > 0)
153
+ return { names: [...new Set(names)].sort(), source: "installed-metadata", evidence: `${rel}/${file}` };
154
+ }
155
+ catch {
156
+ // no such file: try the next
157
+ }
158
+ }
159
+ }
160
+ }
161
+ return null;
162
+ }
163
+ /** The module name(s) a distribution is imported as, and how that was established. */
164
+ export function pythonImportNames(root, dist) {
165
+ const installed = root ? readInstalledNames(root, dist) : null;
166
+ if (installed)
167
+ return installed;
168
+ const known = KNOWN_IMPORT_NAMES[normalizePypiName(dist)];
169
+ if (known)
170
+ return { names: known, source: "known-mapping" };
171
+ return { names: [normalizePypiName(dist).replace(/-/g, "_")], source: "assumed" };
172
+ }
173
+ /** True when module `mod` is `name` or inside it (`yaml.loader` for `yaml`). */
174
+ const underModule = (mod, name) => mod === name || mod.startsWith(`${name}.`);
175
+ /**
176
+ * Count imports of any of `names` in one Python file and collect the names it
177
+ * reaches: `from x import A, B` gives A and B; `import x` / `import x as y`
178
+ * gives the attributes used as `x.attr` / `y.attr`.
179
+ */
180
+ export function scanPythonSource(source, names, symbols) {
181
+ let matches = 0;
182
+ const text = source.replace(/\\\r?\n/g, " ");
183
+ const bindings = [];
184
+ // `from x import (a, b,\n c)` and `from x import a, b as c`
185
+ const fromRe = /^[ \t]*from[ \t]+([\w.]+)[ \t]+import[ \t]+(\([^)]*\)|[^\n#]+)/gm;
186
+ for (const m of text.matchAll(fromRe)) {
187
+ if (!names.some((n) => underModule(m[1], n)))
188
+ continue;
189
+ matches++;
190
+ for (const part of m[2].replace(/[()]/g, "").split(",")) {
191
+ const name = part.trim().split(/\s+as\s+/)[0].trim();
192
+ if (/^[A-Za-z_]\w*$/.test(name))
193
+ symbols.add(name);
194
+ }
195
+ }
196
+ const importRe = /^[ \t]*import[ \t]+([^\n#]+)/gm;
197
+ for (const m of text.matchAll(importRe)) {
198
+ for (const part of m[1].split(",")) {
199
+ const [mod, alias] = part.trim().split(/\s+as\s+/).map((s) => s.trim());
200
+ if (!mod || !names.some((n) => underModule(mod, n)))
201
+ continue;
202
+ matches++;
203
+ bindings.push(alias ?? mod);
204
+ }
205
+ }
206
+ // Attribute uses outside the import statements themselves (`from yaml.loader import` names a module, not an attribute).
207
+ const body = text.replace(fromRe, "").replace(importRe, "");
208
+ for (const binding of bindings) {
209
+ const re = new RegExp(String.raw `(?<![\w.])${binding.replace(/\./g, "\\.")}\.([A-Za-z_]\w*)`, "g");
210
+ for (const m of body.matchAll(re))
211
+ symbols.add(m[1]);
212
+ }
213
+ return matches;
214
+ }
215
+ // ---------------------------------------------------------------- Go
216
+ /** True when import path `imp` is module `mod` or a package inside it, not a different major (`mod/v2`). */
217
+ export function goImportInModule(imp, mod) {
218
+ if (imp === mod)
219
+ return true;
220
+ if (!imp.startsWith(`${mod}/`))
221
+ return false;
222
+ const next = imp.slice(mod.length + 1).split("/")[0];
223
+ return !/^v\d+$/.test(next) || goPathMajor(mod) !== null;
224
+ }
225
+ /** The package name an import path is used through: its last element, skipping a `/vN` suffix and gopkg.in's `.vN`. */
226
+ export function goPackageName(imp) {
227
+ const parts = goBasePath(imp).split("/");
228
+ const last = parts[parts.length - 1] ?? imp;
229
+ return last.replace(/\.v\d+$/, "").replace(/^go-/, "").replace(/[-.]/g, "_");
230
+ }
231
+ /**
232
+ * Count imports of a Go module (or packages inside it) in one file and collect
233
+ * the exported identifiers reached through each import's package name.
234
+ */
235
+ export function scanGoSource(source, mod, symbols) {
236
+ const specs = [];
237
+ const spec = /^\s*(?:([A-Za-z_]\w*|\.|_)\s+)?"([^"]+)"/;
238
+ for (const m of source.matchAll(/^\s*import\s*\(([\s\S]*?)\)/gm)) {
239
+ for (const line of m[1].split("\n")) {
240
+ const s = spec.exec(line.replace(/\/\/.*$/, ""));
241
+ if (s)
242
+ specs.push({ alias: s[1] ?? null, path: s[2] });
243
+ }
244
+ }
245
+ for (const m of source.matchAll(/^\s*import\s+(?:([A-Za-z_]\w*|\.|_)\s+)?"([^"]+)"/gm)) {
246
+ specs.push({ alias: m[1] ?? null, path: m[2] });
247
+ }
248
+ let matches = 0;
249
+ for (const s of specs) {
250
+ if (!goImportInModule(s.path, mod))
251
+ continue;
252
+ matches++;
253
+ const local = s.alias ?? goPackageName(s.path);
254
+ if (local === "_" || local === ".")
255
+ continue;
256
+ for (const m of source.matchAll(new RegExp(String.raw `(?<![\w.])${local}\.([A-Z]\w*)`, "g")))
257
+ symbols.add(m[1]);
258
+ }
259
+ return matches;
260
+ }
@@ -85,6 +85,19 @@ export interface SummaryInput {
85
85
  /** missing = no changelog file; no_entries = a file with no section for these releases; partial = some of them. */
86
86
  changelog: "missing" | "no_entries" | "partial" | "complete";
87
87
  advisoriesFixed: number | null;
88
+ /** Versions as the ecosystem writes them (`v1.2.3`, `3.0rc1`); `from`/`to` are their semver reading. */
89
+ fromLabel?: string;
90
+ toLabel?: string;
91
+ /** Where the entries came from when not the archive ("GitHub release notes"). */
92
+ changelogFrom?: string | null;
93
+ /** How to get a changelog that is missing (the opt-in GitHub fallback), when it would help. */
94
+ githubHint?: string | null;
95
+ /** Go: the upgrade moves to another module path. */
96
+ pathChange?: {
97
+ from: string;
98
+ to: string;
99
+ files: number;
100
+ } | null;
88
101
  }
89
102
  /** The one sentence a human or agent reads first. */
90
103
  export declare function summarize(input: SummaryInput): string;
@@ -84,7 +84,10 @@ export function shapeChangelog(sections, symbols, format) {
84
84
  if (format === "concise") {
85
85
  const important = entries.filter((e) => e.kind !== "change");
86
86
  const others = entries.filter((e) => e.kind === "change");
87
- const additive = (e) => (e.under ? classifyHeading(e.under) === "additive" : false);
87
+ const additive = (e) => {
88
+ const ctx = e.under ? classifyHeading(e.under) : null;
89
+ return ctx === "additive" || ctx === "fixes";
90
+ };
88
91
  const neutral = others.filter((e) => !additive(e)).slice(0, CONCISE_NEUTRAL_PER_VERSION);
89
92
  const minor = others.filter(additive).slice(0, CONCISE_ADDITIVE_PER_VERSION);
90
93
  // Keep document order among the kept plain changes.
@@ -186,10 +189,11 @@ export function summarize(input) {
186
189
  ].filter(Boolean);
187
190
  let breaking;
188
191
  const atLeast = input.changelog === "partial" ? "at least " : "";
192
+ const hint = input.githubHint ? `; ${input.githubHint}` : "";
189
193
  if (input.changelog === "missing")
190
- breaking = "no changelog in the package archive (see release_notes_url)";
194
+ breaking = `no changelog in the package archive, so breaking changes are unknown (see release_notes_url${hint})`;
191
195
  else if (input.changelog === "no_entries") {
192
- breaking = "the package's changelog has no entries for these releases, so breaking changes are unknown (see release_notes_url)";
196
+ breaking = `the package's changelog has no entries for these releases, so breaking changes are unknown (see release_notes_url${hint})`;
193
197
  }
194
198
  else {
195
199
  // "flagged", not "N breaking changes": the flags come from headings and
@@ -202,7 +206,12 @@ export function summarize(input) {
202
206
  }
203
207
  else
204
208
  breaking = flagged;
209
+ if (input.changelogFrom)
210
+ breaking += ` (from ${input.changelogFrom})`;
205
211
  }
206
212
  const advisories = input.advisoriesFixed === null ? "advisories unknown (OSV unreachable)" : `${input.advisoriesFixed} advisories fixed`;
207
- return `${input.pkg} ${input.from} -> ${input.to}: ${parts.join(", ")}, ${breaking}, ${advisories}.`;
213
+ const path = input.pathChange
214
+ ? `, module path changes to ${input.pathChange.to} (${input.pathChange.files} ${input.pathChange.files === 1 ? "file imports" : "files import"} ${input.pathChange.from})`
215
+ : "";
216
+ return `${input.pkg} ${input.fromLabel ?? input.from} -> ${input.toLabel ?? input.to}: ${parts.join(", ")}${path}, ${breaking}, ${advisories}.`;
208
217
  }
@@ -8,12 +8,19 @@
8
8
  * (breaking / deprecation / security), and cross-references the breaking
9
9
  * ones against the symbols this project actually imports from the package.
10
10
  *
11
- * Why the changelog comes from the archive and nowhere else: an agent asked
12
- * to bump fastembed 5 -> 7 otherwise reads GitHub release pages one by one, or
11
+ * Why the changelog comes from the archive first: an agent asked to bump
12
+ * fastembed 5 -> 7 otherwise reads GitHub release pages one by one, or
13
13
  * guesses from the version number. The archive is on the registry the
14
14
  * package manager already trusts, so reading it discloses nothing new
15
- * (Decision 1, option A). When the archive has no changelog the report says
16
- * so and gives `release_notes_url`; it never fetches that page itself.
15
+ * (Decision 1, option A). When the archive has no changelog for the range,
16
+ * GitHub release notes are read only if the user opted in
17
+ * (FOURDA_GITHUB_CHANGELOG, Decision 1 option B); otherwise the report says
18
+ * the changelog is unavailable, how to enable the fallback, and gives
19
+ * `release_notes_url`. It never fetches a docs page itself.
20
+ *
21
+ * Ecosystems: npm, crates.io, PyPI (PEP 440 versions, sdist changelogs,
22
+ * distribution -> import-name mapping) and Go (module proxy, module-zip
23
+ * changelogs, major versions as separate module paths).
17
24
  *
18
25
  * Changelog text is third-party input: entries are sanitised (control and
19
26
  * bidi characters stripped, 400-char cap) and `_meta.untrusted_text` tells
@@ -46,6 +53,8 @@ export interface UpgradeImpactContext {
46
53
  projectRoot: string | null;
47
54
  installed: InstalledDep[];
48
55
  offline: boolean;
56
+ /** FOURDA_GITHUB_CHANGELOG: read GitHub release notes when the archive has none (opt-in, default off). */
57
+ githubChangelog?: boolean;
49
58
  }
50
59
  export declare const upgradeImpactTool: {
51
60
  name: string;