@4da/mcp-server 6.0.2 → 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 (119) hide show
  1. package/CHANGELOG.md +433 -0
  2. package/README.md +26 -18
  3. package/dist/db.d.ts +73 -8
  4. package/dist/db.js +180 -43
  5. package/dist/doctor.d.ts +1 -1
  6. package/dist/doctor.js +15 -9
  7. package/dist/embeddings.js +19 -7
  8. package/dist/gitignore.js +9 -3
  9. package/dist/index.js +43 -4
  10. package/dist/live/cache.d.ts +31 -2
  11. package/dist/live/cache.js +113 -1
  12. package/dist/live/changelog-classify.d.ts +9 -3
  13. package/dist/live/changelog-classify.js +125 -12
  14. package/dist/live/changelog.d.ts +22 -3
  15. package/dist/live/changelog.js +132 -38
  16. package/dist/live/dev-scope.d.ts +2 -2
  17. package/dist/live/fix-paths.d.ts +118 -0
  18. package/dist/live/fix-paths.js +498 -0
  19. package/dist/live/github-releases.d.ts +69 -0
  20. package/dist/live/github-releases.js +252 -0
  21. package/dist/live/go-registry.d.ts +5 -0
  22. package/dist/live/go-registry.js +1 -1
  23. package/dist/live/go-releases.d.ts +48 -0
  24. package/dist/live/go-releases.js +196 -0
  25. package/dist/live/index.d.ts +34 -8
  26. package/dist/live/index.js +66 -7
  27. package/dist/live/lockfile-parents.d.ts +61 -0
  28. package/dist/live/lockfile-parents.js +360 -0
  29. package/dist/live/lockfile-parsers-pygo.d.ts +31 -0
  30. package/dist/live/lockfile-parsers-pygo.js +178 -28
  31. package/dist/live/lockfile-parsers.d.ts +4 -1
  32. package/dist/live/lockfile-parsers.js +57 -24
  33. package/dist/live/lockfile-types.d.ts +12 -3
  34. package/dist/live/lockfile-types.js +2 -1
  35. package/dist/live/osv-ranges.d.ts +85 -0
  36. package/dist/live/osv-ranges.js +136 -0
  37. package/dist/live/osv-scanner.d.ts +1 -0
  38. package/dist/live/osv-scanner.js +85 -78
  39. package/dist/live/package-archive.d.ts +2 -0
  40. package/dist/live/package-archive.js +7 -4
  41. package/dist/live/pypi-releases.d.ts +44 -0
  42. package/dist/live/pypi-releases.js +103 -0
  43. package/dist/live/range-satisfies.d.ts +21 -0
  44. package/dist/live/range-satisfies.js +190 -0
  45. package/dist/live/rate-limiter.d.ts +6 -0
  46. package/dist/live/rate-limiter.js +5 -0
  47. package/dist/live/registry-health.js +2 -0
  48. package/dist/live/release-metadata.d.ts +17 -8
  49. package/dist/live/release-metadata.js +6 -3
  50. package/dist/live/resolution.d.ts +11 -1
  51. package/dist/live/resolution.js +16 -6
  52. package/dist/live/semver-utils.d.ts +8 -0
  53. package/dist/live/semver-utils.js +10 -2
  54. package/dist/live/types.d.ts +17 -1
  55. package/dist/live/upgrade-go.d.ts +62 -0
  56. package/dist/live/upgrade-go.js +130 -0
  57. package/dist/live/upgrade-pypi.d.ts +42 -0
  58. package/dist/live/upgrade-pypi.js +85 -0
  59. package/dist/live/upgrade-sources.d.ts +30 -11
  60. package/dist/live/upgrade-sources.js +93 -21
  61. package/dist/live/zip-archive.d.ts +22 -0
  62. package/dist/live/zip-archive.js +98 -0
  63. package/dist/memory-store.d.ts +19 -0
  64. package/dist/memory-store.js +8 -0
  65. package/dist/native-bindings.d.ts +7 -2
  66. package/dist/native-bindings.js +4 -0
  67. package/dist/project-tree.d.ts +50 -8
  68. package/dist/project-tree.js +121 -29
  69. package/dist/schema-registry.js +5 -5
  70. package/dist/server-instructions.d.ts +6 -0
  71. package/dist/server-instructions.js +20 -15
  72. package/dist/sqlite-driver.d.ts +134 -0
  73. package/dist/sqlite-driver.js +359 -0
  74. package/dist/tool-dispatch.d.ts +8 -1
  75. package/dist/tool-dispatch.js +17 -2
  76. package/dist/tools/agent-memory.js +10 -2
  77. package/dist/tools/app-plan.d.ts +11 -1
  78. package/dist/tools/app-plan.js +32 -6
  79. package/dist/tools/briefing-task-scope.d.ts +21 -2
  80. package/dist/tools/briefing-task-scope.js +97 -4
  81. package/dist/tools/decision-memory.js +9 -3
  82. package/dist/tools/dependency-check-signals.d.ts +23 -5
  83. package/dist/tools/dependency-check-signals.js +62 -19
  84. package/dist/tools/dependency-check-versions.d.ts +94 -0
  85. package/dist/tools/dependency-check-versions.js +213 -0
  86. package/dist/tools/dependency-check.d.ts +27 -11
  87. package/dist/tools/dependency-check.js +321 -74
  88. package/dist/tools/dependency-health.d.ts +1 -1
  89. package/dist/tools/dependency-health.js +5 -0
  90. package/dist/tools/knowledge-gap-ranges.d.ts +1 -1
  91. package/dist/tools/knowledge-gap-ranges.js +1 -1
  92. package/dist/tools/knowledge-gap-scope.d.ts +2 -2
  93. package/dist/tools/project-scope.d.ts +45 -0
  94. package/dist/tools/project-scope.js +119 -0
  95. package/dist/tools/record-feedback.js +1 -1
  96. package/dist/tools/upgrade-impact-callsites.d.ts +1 -0
  97. package/dist/tools/upgrade-impact-callsites.js +23 -5
  98. package/dist/tools/upgrade-impact-changelog.d.ts +41 -0
  99. package/dist/tools/upgrade-impact-changelog.js +79 -0
  100. package/dist/tools/upgrade-impact-imports.d.ts +42 -0
  101. package/dist/tools/upgrade-impact-imports.js +260 -0
  102. package/dist/tools/upgrade-impact-literals.js +1 -1
  103. package/dist/tools/upgrade-impact-report.d.ts +23 -0
  104. package/dist/tools/upgrade-impact-report.js +45 -6
  105. package/dist/tools/upgrade-impact.d.ts +13 -4
  106. package/dist/tools/upgrade-impact.js +236 -85
  107. package/dist/tools/upgrade-planner-fixpaths.d.ts +40 -0
  108. package/dist/tools/upgrade-planner-fixpaths.js +0 -0
  109. package/dist/tools/upgrade-planner.d.ts +31 -3
  110. package/dist/tools/upgrade-planner.js +74 -19
  111. package/dist/tools/vulnerability-scan-concise.d.ts +13 -1
  112. package/dist/tools/vulnerability-scan-concise.js +15 -3
  113. package/dist/tools/vulnerability-scan-format.d.ts +58 -1
  114. package/dist/tools/vulnerability-scan-format.js +107 -11
  115. package/dist/tools/vulnerability-scan.d.ts +104 -13
  116. package/dist/tools/vulnerability-scan.js +27 -3
  117. package/dist/tools/what-should-i-know.d.ts +22 -2
  118. package/dist/tools/what-should-i-know.js +122 -12
  119. package/package.json +10 -32
package/dist/index.js CHANGED
@@ -51,7 +51,8 @@ import { validateToolArgs } from "./tool-args.js";
51
51
  // Tool registry: listing, schema resources, category metadata
52
52
  import { getCategoryManifest, getSchemaResources, getSlimToolList, getToolSchemaDocument, hasToolSchema, TOOL_REGISTRY, } from "./schema-registry.js";
53
53
  // Map-based tool dispatch (replaces per-tool imports + switch statement)
54
- import { dispatchTool } from "./tool-dispatch.js";
54
+ import { DATABASE_FREE_TOOLS, dispatchTool } from "./tool-dispatch.js";
55
+ import { SqliteUnavailableError, sqliteDriverStatus } from "./sqlite-driver.js";
55
56
  import { getPrompt, listPrompts } from "./prompts.js";
56
57
  import { checkBuildStaleness } from "./build-staleness.js";
57
58
  const __filename = fileURLToPath(import.meta.url);
@@ -107,7 +108,7 @@ function getDatabase() {
107
108
  const cwd = resolveProjectDir();
108
109
  // The root and every independently-locked project below it
109
110
  // (project-tree.ts): a repo root's own lockfile is often not the only one.
110
- const tree = scanProjectTree(cwd);
111
+ const { entries: tree, discovery } = scanProjectTree(cwd);
111
112
  const scan = tree[0].scan;
112
113
  db.populateFromScan(scan);
113
114
  const groups = treeResolutionGroups(tree);
@@ -121,7 +122,7 @@ function getDatabase() {
121
122
  // Initialize live intelligence with per-ecosystem resolved versions
122
123
  // (targets carry platform-gated dep info so advisories can be flagged
123
124
  // platform-relevant for the host).
124
- liveIntel.initFromProjectTree(cwd, groups);
125
+ liveIntel.initFromProjectTree(cwd, groups, discovery);
125
126
  if (liveIntel.isEnabled()) {
126
127
  console.error(`[4DA] Live intelligence: enabled (OSV.dev)`);
127
128
  // The vulnerability scan goes through the warmup so a briefing that
@@ -206,6 +207,39 @@ function getDatabase() {
206
207
  }
207
208
  return db;
208
209
  }
210
+ /**
211
+ * No SQLite driver is usable (Node < 22.13 and better-sqlite3's native module
212
+ * unbuilt): the live layer alone, from a project scan and an in-memory cache,
213
+ * so the dependency tools (DATABASE_FREE_TOOLS) still answer. Every other
214
+ * tool returns the driver error, which says how to fix it.
215
+ */
216
+ function initWithoutDatabase() {
217
+ if (liveIntel)
218
+ return;
219
+ liveIntel = new LiveIntelligence(null);
220
+ setLiveIntelligence(liveIntel);
221
+ const cwd = resolveProjectDir();
222
+ const { entries, discovery } = scanProjectTree(cwd);
223
+ const groups = treeResolutionGroups(entries);
224
+ console.error(`[4DA] No SQLite driver: the dependency tools run without a database (${cwd}).`);
225
+ if (groups.length === 0)
226
+ return;
227
+ liveIntel.initFromProjectTree(cwd, groups, discovery);
228
+ if (liveIntel.isEnabled())
229
+ liveIntel.startVulnerabilityWarmup(cwd);
230
+ }
231
+ /** The database for a tool call; null only for a database-free tool when no SQLite driver is usable. */
232
+ function databaseFor(tool) {
233
+ try {
234
+ return getDatabase();
235
+ }
236
+ catch (error) {
237
+ if (!(error instanceof SqliteUnavailableError) || !DATABASE_FREE_TOOLS.has(tool))
238
+ throw error;
239
+ initWithoutDatabase();
240
+ return null;
241
+ }
242
+ }
209
243
  /** Standalone vs desktop-app database, decided once by the cheap probe (no scan, no resolution). */
210
244
  let standaloneMode = null;
211
245
  function isStandaloneMode() {
@@ -372,7 +406,7 @@ export function buildServer() {
372
406
  }
373
407
  }
374
408
  try {
375
- const database = getDatabase();
409
+ const database = databaseFor(name);
376
410
  return await dispatchTool(name, database, args);
377
411
  }
378
412
  catch (error) {
@@ -517,6 +551,11 @@ async function main() {
517
551
  // -------------------------------------------------------------------------
518
552
  const dbPath = process.env.FOURDA_DB_PATH || undefined;
519
553
  const validation = FourDADatabase.validateDatabase(dbPath);
554
+ const sqlite = sqliteDriverStatus();
555
+ if (!sqlite.driver) {
556
+ console.error(`[4DA] ${sqlite.problem}`);
557
+ console.error(` Dependency tools (${[...DATABASE_FREE_TOOLS].join(", ")}) still run, without a database.`);
558
+ }
520
559
  if (validation.valid) {
521
560
  console.error(`[4DA] Database validated — ${validation.tables?.length ?? 0} tables found`);
522
561
  }
@@ -1,7 +1,29 @@
1
- import type Database from "better-sqlite3";
1
+ import { type SqliteDatabase } from "../sqlite-driver.js";
2
+ /**
3
+ * The server's own cache file (serverCacheDbPath(), `cache.db`), opened once
4
+ * per process. Falls back to an in-memory database when the file cannot be
5
+ * created (a read-only home directory): the server then runs uncached on disk
6
+ * rather than failing. Null when no SQLite driver is usable at all
7
+ * (sqlite-driver.ts); LiveCache then keeps entries in a Map for the session.
8
+ */
9
+ export declare function getServerCacheDb(): SqliteDatabase | null;
10
+ /** Close the shared cache connection (tests; the next getServerCacheDb() reopens it). */
11
+ export declare function closeServerCacheDb(): void;
12
+ /**
13
+ * Registry, OSV and changelog responses with a TTL.
14
+ *
15
+ * The cache never lives in the desktop app's database. A read-only connection
16
+ * (the app's `4da.db`, see FourDADatabase) is replaced by the server's own
17
+ * cache file; until 6.0.2 the server created a `live_cache` table inside the
18
+ * app's database and filled it.
19
+ */
2
20
  export declare class LiveCache {
3
21
  private db;
4
- constructor(db: Database.Database);
22
+ /** Without any SQLite driver: entries for this session only, keyed like the table. */
23
+ private memory;
24
+ constructor(db: SqliteDatabase | null);
25
+ /** The Map store's equivalent of datetime('now') comparisons. */
26
+ private live;
5
27
  private ensureTable;
6
28
  get<T>(key: string): T | null;
7
29
  getStale<T>(key: string): {
@@ -9,6 +31,13 @@ export declare class LiveCache {
9
31
  fetchedAt: string;
10
32
  } | null;
11
33
  set(key: string, data: unknown, source: string, ttlSeconds: number): void;
34
+ /**
35
+ * Many entries in ONE transaction. A scan caches one row per dependency;
36
+ * as separate autocommits each paid its own WAL commit, and superset's
37
+ * 4,516 rows took about 40 s of a 55 s cold scan whose network part ended
38
+ * at 12 s (2026-10-10).
39
+ */
40
+ setMany(entries: Array<[key: string, data: unknown]>, source: string, ttlSeconds: number): void;
12
41
  purgeExpired(): number;
13
42
  invalidateSource(source: string): void;
14
43
  invalidateAll(): void;
@@ -1,12 +1,70 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { openDatabase, sqliteDriverStatus } from "../sqlite-driver.js";
5
+ import { serverCacheDbPath } from "../db.js";
6
+ let serverCache = null;
7
+ /**
8
+ * The server's own cache file (serverCacheDbPath(), `cache.db`), opened once
9
+ * per process. Falls back to an in-memory database when the file cannot be
10
+ * created (a read-only home directory): the server then runs uncached on disk
11
+ * rather than failing. Null when no SQLite driver is usable at all
12
+ * (sqlite-driver.ts); LiveCache then keeps entries in a Map for the session.
13
+ */
14
+ export function getServerCacheDb() {
15
+ if (serverCache?.open)
16
+ return serverCache;
17
+ if (!sqliteDriverStatus().driver)
18
+ return null;
19
+ const file = serverCacheDbPath();
20
+ try {
21
+ fs.mkdirSync(path.dirname(file), { recursive: true });
22
+ serverCache = openDatabase(file);
23
+ serverCache.pragma("journal_mode = WAL");
24
+ // A cache, not a record: in WAL mode NORMAL never corrupts the file, and
25
+ // a crash can only lose the last few writes, which are refetched.
26
+ serverCache.pragma("synchronous = NORMAL");
27
+ }
28
+ catch (error) {
29
+ console.error(`[4da] cannot open the cache at ${file} (${error instanceof Error ? error.message : String(error)}); caching in memory for this session`);
30
+ serverCache = openDatabase(":memory:");
31
+ }
32
+ return serverCache;
33
+ }
34
+ /** Close the shared cache connection (tests; the next getServerCacheDb() reopens it). */
35
+ export function closeServerCacheDb() {
36
+ if (serverCache?.open)
37
+ serverCache.close();
38
+ serverCache = null;
39
+ }
40
+ /**
41
+ * Registry, OSV and changelog responses with a TTL.
42
+ *
43
+ * The cache never lives in the desktop app's database. A read-only connection
44
+ * (the app's `4da.db`, see FourDADatabase) is replaced by the server's own
45
+ * cache file; until 6.0.2 the server created a `live_cache` table inside the
46
+ * app's database and filled it.
47
+ */
2
48
  export class LiveCache {
3
49
  db;
50
+ /** Without any SQLite driver: entries for this session only, keyed like the table. */
51
+ memory = null;
4
52
  constructor(db) {
5
- this.db = db;
53
+ this.db = !db || db.readonly ? getServerCacheDb() : db;
54
+ if (!this.db) {
55
+ this.memory = new Map();
56
+ return;
57
+ }
6
58
  this.ensureTable();
7
59
  this.purgeExpired();
8
60
  }
61
+ /** The Map store's equivalent of datetime('now') comparisons. */
62
+ live(row) {
63
+ return Date.parse(row.expires_at) > Date.now();
64
+ }
9
65
  ensureTable() {
66
+ if (!this.db)
67
+ return;
10
68
  this.db.exec(`
11
69
  CREATE TABLE IF NOT EXISTS live_cache (
12
70
  cache_key TEXT PRIMARY KEY,
@@ -20,6 +78,10 @@ export class LiveCache {
20
78
  `);
21
79
  }
22
80
  get(key) {
81
+ if (this.memory) {
82
+ const hit = this.memory.get(key);
83
+ return hit && this.live(hit) ? JSON.parse(hit.data) : null;
84
+ }
23
85
  const row = this.db.prepare("SELECT data FROM live_cache WHERE cache_key = ? AND expires_at > datetime('now')").get(key);
24
86
  if (!row)
25
87
  return null;
@@ -31,6 +93,10 @@ export class LiveCache {
31
93
  }
32
94
  }
33
95
  getStale(key) {
96
+ if (this.memory) {
97
+ const hit = this.memory.get(key);
98
+ return hit ? { data: JSON.parse(hit.data), fetchedAt: hit.fetched_at } : null;
99
+ }
34
100
  const row = this.db.prepare("SELECT data, fetched_at FROM live_cache WHERE cache_key = ?").get(key);
35
101
  if (!row)
36
102
  return null;
@@ -42,19 +108,65 @@ export class LiveCache {
42
108
  }
43
109
  }
44
110
  set(key, data, source, ttlSeconds) {
111
+ if (this.memory) {
112
+ const now = Date.now();
113
+ const iso = (ms) => new Date(ms).toISOString();
114
+ this.memory.set(key, { cache_key: key, data: JSON.stringify(data), source, fetched_at: iso(now), expires_at: iso(now + ttlSeconds * 1000) });
115
+ return;
116
+ }
45
117
  this.db.prepare(`
46
118
  INSERT OR REPLACE INTO live_cache (cache_key, data, source, fetched_at, expires_at)
47
119
  VALUES (?, ?, ?, datetime('now'), datetime('now', '+' || ? || ' seconds'))
48
120
  `).run(key, JSON.stringify(data), source, ttlSeconds);
49
121
  }
122
+ /**
123
+ * Many entries in ONE transaction. A scan caches one row per dependency;
124
+ * as separate autocommits each paid its own WAL commit, and superset's
125
+ * 4,516 rows took about 40 s of a 55 s cold scan whose network part ended
126
+ * at 12 s (2026-10-10).
127
+ */
128
+ setMany(entries, source, ttlSeconds) {
129
+ if (entries.length === 0)
130
+ return;
131
+ if (this.memory) {
132
+ for (const [key, data] of entries)
133
+ this.set(key, data, source, ttlSeconds);
134
+ return;
135
+ }
136
+ const insert = this.db.prepare(`
137
+ INSERT OR REPLACE INTO live_cache (cache_key, data, source, fetched_at, expires_at)
138
+ VALUES (?, ?, ?, datetime('now'), datetime('now', '+' || ? || ' seconds'))
139
+ `);
140
+ this.db.transaction(() => {
141
+ for (const [key, data] of entries)
142
+ insert.run(key, JSON.stringify(data), source, ttlSeconds);
143
+ })();
144
+ }
50
145
  purgeExpired() {
146
+ if (this.memory) {
147
+ let purged = 0;
148
+ for (const [key, row] of this.memory)
149
+ if (!this.live(row) && this.memory.delete(key))
150
+ purged++;
151
+ return purged;
152
+ }
51
153
  const result = this.db.prepare("DELETE FROM live_cache WHERE expires_at <= datetime('now')").run();
52
154
  return result.changes;
53
155
  }
54
156
  invalidateSource(source) {
157
+ if (this.memory) {
158
+ for (const [key, row] of this.memory)
159
+ if (row.source === source)
160
+ this.memory.delete(key);
161
+ return;
162
+ }
55
163
  this.db.prepare("DELETE FROM live_cache WHERE source = ?").run(source);
56
164
  }
57
165
  invalidateAll() {
166
+ if (this.memory) {
167
+ this.memory.clear();
168
+ return;
169
+ }
58
170
  this.db.prepare("DELETE FROM live_cache").run();
59
171
  }
60
172
  }
@@ -20,8 +20,14 @@ export type EntryKind = "breaking" | "deprecation" | "security" | "change";
20
20
  export declare const MAX_ENTRY_CHARS = 400;
21
21
  /** Strip control and invisible/bidi characters, collapse whitespace, cap length. */
22
22
  export declare function sanitizeEntry(text: string, cap?: number): string;
23
+ /**
24
+ * The text classified: RST ``literals`` read as `literals`, link targets and
25
+ * bare URLs dropped (a `go.dev/doc/go1.21` link made "Drop support for Go 1.21"
26
+ * read as a docs change), GitHub's "by @user in <PR url>" suffix dropped.
27
+ */
28
+ export declare function classificationText(text: string): string;
23
29
  /** Kind of a single changelog line by its own wording. */
24
- export declare function classifyText(text: string): EntryKind;
30
+ export declare function classifyText(raw: string): EntryKind;
25
31
  /**
26
32
  * What a sub-heading (or a label-only bullet such as express's "* remove:")
27
33
  * says about the entries under it:
@@ -34,8 +40,8 @@ export declare function classifyText(text: string): EntryKind;
34
40
  * where only an explicit marker makes an entry breaking ("Added a clear()
35
41
  * function ... removed from an axios instance" is not a removal).
36
42
  */
37
- export type EntryContext = EntryKind | "removal" | "additive";
43
+ export type EntryContext = EntryKind | "removal" | "additive" | "fixes";
38
44
  /** Context implied by a heading or label, or null when it carries no signal ("### Changed"). */
39
45
  export declare function classifyHeading(heading: string): EntryContext | null;
40
46
  /** Final kind: an explicit heading or bullet parent wins; removal and additive contexts refine the line's wording. */
41
- export declare function classifyEntry(text: string, context: EntryContext | null): EntryKind;
47
+ export declare function classifyEntry(raw: string, context: EntryContext | null): EntryKind;
@@ -35,7 +35,8 @@ export function sanitizeEntry(text, cap = MAX_ENTRY_CHARS) {
35
35
  // `BREAKING` but not the file name `BREAKING-CHANGES.md` (ratatui links it from routine entries);
36
36
  // a leading "Breaking -" / "[Breaking]" label too (webpack-merge 5, handlebars-rust).
37
37
  // Case-sensitive on purpose: lowercase "breaking" is prose, handled (with negation) below.
38
- const EXPLICIT_BREAKING_MARKER = /\bBREAKING\b(?![-_.]\w)|⚠|^\s*\*{0,2}\w+(?:\([^)]*\))?!:|^\s*\*{0,2}[Bb]reaking\*{0,2}\s*(?::|[-–—]\s)|^\s*\[(?:[Bb]reaking|[Rr]emoved)\]/;
38
+ // "*Backwards-incompatible*:" (marshmallow 4) is the same call in other words.
39
+ const EXPLICIT_BREAKING_MARKER = /\bBREAKING\b(?![-_.]\w)|⚠|^\s*\*{0,2}\w+(?:\([^)]*\))?!:|^\s*\*{0,2}[Bb]reaking\*{0,2}\s*(?::|[-–—]\s)|^\s*\[(?:[Bb]reaking|[Rr]emoved)\]|\b[Bb]ackwards?[- ][Ii]ncompatible\b/;
39
40
  /**
40
41
  * Text ABOUT breaking changes rather than one: a pointer to the list ("⚠️ List
41
42
  * of breaking changes can be found here"), a CI label check, a docs fix
@@ -126,7 +127,81 @@ const BREAKING_HEURISTIC = new RegExp([
126
127
  "\\bincompatib",
127
128
  ].join("|"), "i");
128
129
  /** Removals that change nothing a caller sees: "Removed unused imports", "Removed Webpack". */
129
- const INTERNAL_CHANGE = /\b(?:unused|unneeded|unnecessary|internal(?:ly)?|dead code|tests?|ci|lint(?:ing)?|typos?|comments?|docs?|documentation|readme|redundant|duplicated?|webpack|dev-?dependenc(?:y|ies)|dependency|warnings?|examples?|benchmarks?|release process|build process|tooling|usage|incorrect|wrong|erroneous|stray|spurious)\b/i;
130
+ const INTERNAL_CHANGE = /\b(?:unused|unneeded|unnecessary|internal(?:ly)?|dead code|tests?|ci|lint(?:ing)?|typos?|comments?|docs?|documentation|readme|redundant|duplicated?|webpack|dev-?dependenc(?:y|ies)|dependency|warnings?|examples?|benchmarks?|release process|build process|tooling|usage|incorrect|wrong|erroneous|stray|spurious|private|maintainers?|contributors?|invalid|broken|bogus|[\w-]+\.(?:md|json|ya?ml|toml|lock|txt|cfg)(?![\w(]))\b/i;
131
+ /**
132
+ * The removed thing is private by its name (`_prepare_conn`, a leading
133
+ * underscore): urllib3 2.0 lists such removals under "Removed".
134
+ */
135
+ const PRIVATE_NAME = /^\W*(?:remove[sd]?|delete[sd]?|drop(?:s|ped)?)\b[^`]{0,40}`_[A-Za-z]/i;
136
+ const isInternal = (t) => INTERNAL_CHANGE.test(t) || PRIVATE_NAME.test(t);
137
+ /** Public API made private ("Assorted array helper methods and types made private"): a removal, though it says "private". */
138
+ const MADE_PRIVATE = /\b(?:made|make|makes|is now|are now)\b[^.]{0,60}\bprivate\b/i;
139
+ /**
140
+ * The entry says it changes nothing for users: "passing string or boolean still
141
+ * works as before", "This change should have no effect on users".
142
+ */
143
+ const STATED_COMPATIBLE = /\b(?:still works?|still work as before|continues? to work|(?:is|are) still supported|remains? supported|(?:should|will|has|have) (?:have )?no (?:effect|impact) on (?:users|you|most users|existing code)|no user-facing)\b|(?<!\bnot |\bnon-?)\bbackwards?[- ]compatib(?:le|ility)\b/i;
144
+ /**
145
+ * Not an API change although worded like one: undoing a removal or deprecation
146
+ * ("removes the deprecation notice from", "restore the `X()` function"), a
147
+ * relaxation ("remove Send bounds for request `Body`"), a repository rename, a
148
+ * swapped dependency ("Move from `winapi` to `windows` crate").
149
+ */
150
+ const UNDO = /\bremov\w*\s+(?:the\s+)?(?:[\w`]+\s+)?(?:bounds?|restrictions?|limitations?|limits?|requirements?|constraints?|need for)\b|\brenam\w*\s+(?:the\s+)?(?:github\s+)?repo(?:sitory)?\b|\bmov(?:e|ed|es)\s+from\s+\x60[^\x60]+\x60\s+to\s+\x60[^\x60]+\x60\s+(?:crate|package|library)\b|\bremov\w*\s+(?:the\s+)?deprecation\s+(?:notices?|warnings?|messages?)\b|\bun-?deprecat|\b(?:restore[sd]?|restoring|re-?add(?:ed|s)?|reintroduc\w*)\b/i;
151
+ /** A change confined to examples, docs, tests or CI: "Drop support for Go 1.21 in dice example". */
152
+ const SCOPED_NON_API = /\bin (?:the )?(?:[\w-]+ )?(?:examples?|docs?|documentation|tests?|ci|workflows?|benchmarks?|readme)\b/i;
153
+ const LEAD = String.raw `^\W*(?:\*\*[^*]{1,40}\*\*:?\s*)?`;
154
+ /**
155
+ * Runtime and language support: dropped versions and raised minimums ("drop
156
+ * node 10.x", "Drop support for Go 1.21", "only support node 16-20", "Replace
157
+ * go 1.21 with go 1.22 in go mod", "increase MSRV to 1.63"). Breaking whatever
158
+ * the reason the entry gives ("drop node 10.x to upgrade dev dependencies").
159
+ */
160
+ const RUNTIME_SUPPORT = new RegExp([
161
+ String.raw `\b(?:drop(?:s|ped|ping)?|remove[sd]?|removing|end(?:s|ed)?)\s+(?:official\s+)?(?:support\s+(?:for\s+)?)?(?:node(?:\.?js)?|python|py|go|golang|ruby|php|java|jdk|rust|rustc)\s*v?\d`,
162
+ String.raw `\b(?:only supports?|now requires?|requires? at least|minimum (?:supported )?(?:version of )?)\s*(?:node(?:\.?js)?|python|go|golang|rust|ruby|php|java)\s*v?\d`,
163
+ String.raw `\bonly supports? (?:node(?:\.?js)?|python|go|golang)\b`,
164
+ String.raw `\bgo\s*1\.\d+[^.]{0,30}\bgo\.?\s?mod\b`,
165
+ String.raw `\b(?:bump|bumped|raise|raised|increase|increased|update|updated|require|requires|now)\b.{0,60}\b(?:MSRV|minimum supported rust version|rust-version)\b.{0,30}\d+\.\d+`,
166
+ ].join("|"), "i");
167
+ /**
168
+ * Wording that changes the API whatever heading it sits under (rule set added
169
+ * 2026-10-11 from the G3 panel's dev pairs): a leading removal or rename of
170
+ * something API-shaped, a changed default, a type made private, a move, a
171
+ * replacement, keyword-only arguments, "now ... instead of".
172
+ */
173
+ const API_CHANGE = new RegExp([
174
+ // "remove UMD build", "remove `ReasonPhrase::from_bytes_unchecked()` method", "Remove previously deprecated code".
175
+ // The removed thing is named within the next few words, as in REMOVAL_HEURISTIC: "Remove a window when ...
176
+ // `cd`able by non-owners" (a security fix) has its backtick far later.
177
+ String.raw `${LEAD}(?:remove[sd]?|delete[sd]?|drop(?:s|ped)?)\b(?=(?:\s+[\w.:/-]+){0,4}\s*\(?\x60|(?:\s+[\w.:/-]+){0,5}?\s+(?:support|builds?|param(?:eter)?s?|options?|methods?|functions?|modules?|class(?:es)?|types?|exports?|features?|flags?|apis?|prefix(?:es)?|constructors?|requires|imports|entry ?points?|fields?|propert(?:y|ies)|arguments?|attributes?|traits?|structs?|enums?|macros?|commands?|settings?|config|keys?|aliases?|fallback|deprecated|code)\b)`,
178
+ String.raw `${LEAD}renamed?\b`,
179
+ String.raw `\x60[^\x60]+\x60\s+(?:is |was |has been )?renamed\b|\bchanged? (?:the )?name of\b`,
180
+ // Defaults: "Changed the default `blocksize` to 16KB", "now defaults `extended` to `false`", "default `header_read_timeout` to 30 seconds".
181
+ String.raw `${LEAD}(?:change[sd]?|set|make|made)\b[^.]{0,60}\bdefaults?\b[^.]{0,60}\bto\b`,
182
+ String.raw `${LEAD}default\s[^.]{0,60}\bto\b`,
183
+ String.raw `\bnow defaults?\b[^.]{0,40}\bto\b|\bdefaults? (?:value |values )?(?:is|are) now\b`,
184
+ String.raw `\b(?:keyword|positional)[- ]only\b`,
185
+ String.raw `\x60[^\x60]+\x60\s+(?:has been |have been |was |were |is |are )?moved (?:to|into|out of)\b`,
186
+ String.raw `${LEAD}moved?\b[^.]{0,80}\x60[^\x60]+\x60[^.]{0,60}\b(?:to|into|out of)\b`,
187
+ String.raw `\b(?:are|is|was|were|been)\s+(?:both\s+)?replaced\b|\x60[^\x60]+\x60\s+replaces\s+\x60`,
188
+ String.raw `\bnow\b[^.]{0,60}\binstead of\b`,
189
+ String.raw `\b(?:accepts?|takes?|returns?|receives?)\s+(?:a |an )?(?:pointer|reference|value|slice|copy)\b[^.]{0,30}\binstead of\b`,
190
+ String.raw `\bchang(?:e|ed|es)\b[^.]{0,80}\bfrom\s+\x60[^\x60]+\x60\s+to\s+\x60`,
191
+ String.raw `\bno longer (?:subclass(?:es)?|inherits?|comparable|usable|considers?|escapes?|initiali[sz]ed|quotes?|sets?|ha(?:s|ve) a default|safe|valid|recogni[sz]ed|parsed?|emitted|exposed|called)\b|\bis not set anymore\b|\bnot set anymore\b`,
192
+ ].join("|"), "i");
193
+ /**
194
+ * The text classified: RST ``literals`` read as `literals`, link targets and
195
+ * bare URLs dropped (a `go.dev/doc/go1.21` link made "Drop support for Go 1.21"
196
+ * read as a docs change), GitHub's "by @user in <PR url>" suffix dropped.
197
+ */
198
+ export function classificationText(text) {
199
+ return text
200
+ .replace(/``/g, "`")
201
+ .replace(/\]\([^)]*\)/g, "]")
202
+ .replace(/\bby @[\w-]+(?:\[bot\])? in\s+\S+/g, " ")
203
+ .replace(/https?:\/\/\S+/g, " ");
204
+ }
130
205
  const DEPRECATION = /deprecat/i;
131
206
  /**
132
207
  * An entry whose main verb is "deprecate" is a deprecation even when it names
@@ -135,11 +210,14 @@ const DEPRECATION = /deprecat/i;
135
210
  */
136
211
  const LEADING_DEPRECATION = /^\W*(?:\*\*[^*]{1,40}\*\*:?\s*)?(?:deprecated?|deprecates|deprecating)\b/i;
137
212
  /** Kind of a single changelog line by its own wording. */
138
- export function classifyText(text) {
213
+ export function classifyText(raw) {
214
+ const text = classificationText(raw);
139
215
  if (META_BREAKING.test(text))
140
216
  return SECURITY.test(text) ? "security" : "change";
141
217
  if (EXPLICIT_BREAKING_MARKER.test(text))
142
218
  return "breaking";
219
+ if (STATED_COMPATIBLE.test(text))
220
+ return DEPRECATION.test(text) ? "deprecation" : "change";
143
221
  if (NON_BREAKING_LABEL.test(text)) {
144
222
  if (SECURITY.test(text))
145
223
  return "security";
@@ -158,47 +236,82 @@ export function classifyText(text) {
158
236
  return "security";
159
237
  if (FUTURE_INTENT.test(text))
160
238
  return DEPRECATION.test(text) ? "deprecation" : "change";
161
- if (BREAKING_HEURISTIC.test(text))
162
- return "breaking";
163
- if (REMOVAL_HEURISTIC.test(text) && !INTERNAL_CHANGE.test(text) && !LEADING_FIX.test(text))
239
+ if (UNDO.test(text))
240
+ return DEPRECATION.test(text) ? "deprecation" : "change";
241
+ if (!SCOPED_NON_API.test(text)) {
242
+ if (RUNTIME_SUPPORT.test(text) || BREAKING_HEURISTIC.test(text))
243
+ return "breaking";
244
+ if (MADE_PRIVATE.test(text) && !LEADING_FIX.test(text))
245
+ return "breaking";
246
+ if (API_CHANGE.test(text) && !isInternal(text) && !LEADING_FIX.test(text))
247
+ return "breaking";
248
+ }
249
+ if (REMOVAL_HEURISTIC.test(text) && !isInternal(text) && !LEADING_FIX.test(text))
164
250
  return "breaking";
165
251
  if (DEPRECATION.test(text))
166
252
  return "deprecation";
167
253
  return "change";
168
254
  }
169
- const ADDITIVE_HEADING = /^\W*(?:added|adds|new|new features?|features?|enhancements?|improvements?|fixed|fixes|bug ?fixes|performance(?: improvements)?|perf|optimi[sz]ations?|docs?|documentation|tests?|testing|internal|chores?|ci|build|refactor(?:ing)?|misc(?:ellaneous)?|other(?: changes)?)\W*$/i;
255
+ const FIX_HEADING = /^\W*(?:fixed|fixes|bug ?fixes|bugfixes|fix)\W*$/i;
256
+ // "Other changes" / "Misc" are not additive: pgx 5 files its type removals and renames
257
+ // under "Other Changes", and marshmallow 4 a changed base class.
258
+ const ADDITIVE_HEADING = /^\W*(?:added|adds|new|new features?|features?|enhancements?|improvements?|fixed|fixes|bug ?fixes|performance(?: improvements)?|perf|optimi[sz]ations?|docs?|documentation|tests?|testing|internal|chores?|ci|build|refactor(?:ing)?)\W*$/i;
170
259
  /** Context implied by a heading or label, or null when it carries no signal ("### Changed"). */
171
260
  export function classifyHeading(heading) {
172
261
  if (/(?<!-)\bbreaking\b|⚠|incompatib|migration|\bmajor changes?\b/i.test(heading))
173
262
  return "breaking";
174
- if (/\bremov(?:e|ed|als?)\b|\bapi changes?\b/i.test(heading))
263
+ // "Removed", "Removals", "Deprecated and Removed", "API changes": a category.
264
+ // "`Cargo.lock` Removed from Tracking" (sqlx 0.9.0) is a news item about the
265
+ // repository, and its three paragraphs were flagged breaking.
266
+ // "Previously-deprecated APIs have been removed, including" (marshmallow 4) is a removal list, not a deprecation one.
267
+ if (/(?:^\W*|(?:\band|[&/,])\s*)remov(?:e|ed|als?)\b|\bapi changes?\b|\b(?:have|has) been removed\b|\b(?:were|are) removed\b/i.test(heading))
175
268
  return "removal";
176
269
  if (/security|vulnerab/i.test(heading))
177
270
  return "security";
178
271
  if (/deprecat/i.test(heading))
179
272
  return "deprecation";
273
+ // Fixes are additive, and stricter: the author filed the entry as a bug fix, so API-shaped
274
+ // wording in it ("change default export to named function") is not read as a break.
275
+ if (FIX_HEADING.test(heading))
276
+ return "fixes";
180
277
  if (ADDITIVE_HEADING.test(heading))
181
278
  return "additive";
182
279
  return null;
183
280
  }
184
281
  /** Final kind: an explicit heading or bullet parent wins; removal and additive contexts refine the line's wording. */
185
- export function classifyEntry(text, context) {
282
+ export function classifyEntry(raw, context) {
186
283
  if (context === "breaking" || context === "security" || context === "deprecation") {
187
284
  // A security line under "Breaking" stays breaking: the heading is the author's own call.
188
285
  return context;
189
286
  }
287
+ const text = classificationText(raw);
190
288
  if (context === "removal") {
191
289
  if (META_BREAKING.test(text))
192
290
  return "change";
193
291
  if (EXPLICIT_BREAKING_MARKER.test(text))
194
292
  return "breaking";
195
- if (DEPRECATION.test(text) && !/\bremov/i.test(text))
293
+ if (STATED_COMPATIBLE.test(text) || UNDO.test(text))
294
+ return DEPRECATION.test(text) ? "deprecation" : "change";
295
+ // A deprecation filed under Removed is one when it leads with it; "`x` (deprecated in 3.24.0)" was removed.
296
+ if (LEADING_DEPRECATION.test(text) || (DEPRECATION.test(text) && !/\bremov|\(deprecated (?:in|since)\b|\bno longer\b|\bwere replaced\b/i.test(text))) {
196
297
  return "deprecation";
197
- return INTERNAL_CHANGE.test(text) ? "change" : "breaking";
298
+ }
299
+ return isInternal(text) ? "change" : "breaking";
198
300
  }
199
- if (context === "additive") {
301
+ if (context === "additive" || context === "fixes") {
200
302
  if (!META_BREAKING.test(text) && EXPLICIT_BREAKING_MARKER.test(text))
201
303
  return "breaking";
304
+ // An API removal, rename or raised runtime minimum is breaking under "Features" too
305
+ // (uuid 11 lists "remove UMD build" there; hyper 1.0 "rename to `Http2ClientConnExec`").
306
+ const quiet = STATED_COMPATIBLE.test(text) || UNDO.test(text) || SCOPED_NON_API.test(text) || NON_BREAKING_LABEL.test(text);
307
+ if (!quiet && !META_BREAKING.test(text)) {
308
+ if (RUNTIME_SUPPORT.test(text))
309
+ return "breaking";
310
+ if (context === "additive" && MADE_PRIVATE.test(text) && !LEADING_FIX.test(text))
311
+ return "breaking";
312
+ if (context === "additive" && API_CHANGE.test(text) && !isInternal(text) && !LEADING_FIX.test(text))
313
+ return "breaking";
314
+ }
202
315
  if (SECURITY.test(text))
203
316
  return "security";
204
317
  return DEPRECATION.test(text) ? "deprecation" : "change";
@@ -7,6 +7,8 @@
7
7
  * conventional `## [7.0.0](https://…/compare/…) (2025-06-24)`
8
8
  * plain / prefixed `## 1.2.3`, `# v1.2.3 (2024-01-01)`, `### Version 1.2.3`, `## fastembed 5.0.0`
9
9
  * setext `1.2.3` underlined with `===` / `---`
10
+ * reStructuredText `Version 3.0.0` underlined (or over- and underlined)
11
+ * with any of `= - ~ ^ * + # " '`, then `Released 2023-09-30`
10
12
  * History.md (express) `1.2.3 / 2020-01-01`
11
13
  * A heading only opens a section when the rest of it looks like a trailer
12
14
  * (date, link, "yanked") — `## Upgrading from 1.x` or `#### Rust 1.70 support`
@@ -17,6 +19,13 @@
17
19
  * reference definitions are skipped: they are examples and URLs, not changes.
18
20
  */
19
21
  import { type EntryKind } from "./changelog-classify.js";
22
+ /**
23
+ * Version of the parser AND classifier output. A parsed changelog is cached
24
+ * for 7 days per package version, so a fix to either one stays invisible
25
+ * for a week unless this changes: bump it with every change to parseChangelog
26
+ * or changelog-classify.ts that can alter a section or an entry's kind.
27
+ */
28
+ export declare const CHANGELOG_PARSER_VERSION = 4;
20
29
  export interface ChangelogEntry {
21
30
  kind: EntryKind;
22
31
  text: string;
@@ -26,6 +35,14 @@ export interface ChangelogEntry {
26
35
  * under actix-web's "Removed" reads as harmless, to an agent and to a rater alike.
27
36
  */
28
37
  under?: string;
38
+ /**
39
+ * Bullets nested under this entry (feature lists, "Breaking change: ..."
40
+ * notes). They belong to the entry, not beside it: sqlx 0.9.0's Breaking
41
+ * section has 23 top-level bullets, and counting every nested one reported
42
+ * "83 entries flagged breaking" (2026-10-07). A nested bullet that is more
43
+ * severe than its parent raises the parent's kind.
44
+ */
45
+ details?: string[];
29
46
  }
30
47
  export interface ChangelogSection {
31
48
  version: string;
@@ -49,8 +66,10 @@ export declare function parseVersionHeading(raw: string): {
49
66
  } | null;
50
67
  /** Parse a changelog into version sections, in document order (usually newest first). */
51
68
  export declare function parseChangelog(text: string): ChangelogSection[];
52
- /** True when `version` lies in (from, to] by semver precedence; unreadable versions are out. */
53
- export declare function inRange(version: string, from: string, to: string): boolean;
69
+ /** Version order: 1, -1, 0, or null when either side is unreadable. Semver precedence by default. */
70
+ export type VersionOrder = (a: string, b: string) => number | null;
71
+ /** True when `version` lies in (from, to] by `order`; unreadable versions are out. */
72
+ export declare function inRange(version: string, from: string, to: string, order?: VersionOrder): boolean;
54
73
  /**
55
74
  * Sections in (from, to], plus whether the changelog reaches both ends of the
56
75
  * range: its newest section is at or past `to`, and its oldest at or before
@@ -58,7 +77,7 @@ export declare function inRange(version: string, from: string, to: string): bool
58
77
  * stopped being maintained two majors ago is reported as not covering the
59
78
  * range instead of silently presenting a partial history as the whole one.
60
79
  */
61
- export declare function selectRange(sections: ChangelogSection[], from: string, to: string, firstInRange: string): {
80
+ export declare function selectRange(sections: ChangelogSection[], from: string, to: string, firstInRange: string, order?: VersionOrder): {
62
81
  sections: ChangelogSection[];
63
82
  coversRange: boolean;
64
83
  };