@4da/mcp-server 6.0.1 → 6.1.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 (93) hide show
  1. package/CHANGELOG.md +316 -0
  2. package/README.md +19 -13
  3. package/dist/db.d.ts +88 -7
  4. package/dist/db.js +248 -63
  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 +54 -7
  10. package/dist/live/cache.d.ts +31 -2
  11. package/dist/live/cache.js +113 -1
  12. package/dist/live/changelog-classify.js +4 -1
  13. package/dist/live/changelog.d.ts +15 -0
  14. package/dist/live/changelog.js +67 -11
  15. package/dist/live/dev-scope.d.ts +2 -2
  16. package/dist/live/fix-paths.d.ts +118 -0
  17. package/dist/live/fix-paths.js +498 -0
  18. package/dist/live/go-registry.d.ts +5 -0
  19. package/dist/live/go-registry.js +1 -1
  20. package/dist/live/index.d.ts +30 -3
  21. package/dist/live/index.js +59 -6
  22. package/dist/live/lockfile-parents.d.ts +61 -0
  23. package/dist/live/lockfile-parents.js +360 -0
  24. package/dist/live/lockfile-parsers-pygo.d.ts +31 -0
  25. package/dist/live/lockfile-parsers-pygo.js +178 -28
  26. package/dist/live/lockfile-parsers.d.ts +4 -1
  27. package/dist/live/lockfile-parsers.js +57 -24
  28. package/dist/live/lockfile-types.d.ts +12 -3
  29. package/dist/live/lockfile-types.js +2 -1
  30. package/dist/live/osv-ranges.d.ts +79 -0
  31. package/dist/live/osv-ranges.js +133 -0
  32. package/dist/live/osv-scanner.d.ts +1 -0
  33. package/dist/live/osv-scanner.js +85 -78
  34. package/dist/live/range-satisfies.d.ts +21 -0
  35. package/dist/live/range-satisfies.js +190 -0
  36. package/dist/live/rate-limiter.d.ts +6 -0
  37. package/dist/live/rate-limiter.js +5 -0
  38. package/dist/live/registry-health.js +2 -0
  39. package/dist/live/resolution.d.ts +11 -1
  40. package/dist/live/resolution.js +16 -6
  41. package/dist/live/semver-utils.d.ts +8 -0
  42. package/dist/live/semver-utils.js +10 -2
  43. package/dist/live/types.d.ts +17 -1
  44. package/dist/live/upgrade-sources.d.ts +2 -0
  45. package/dist/live/upgrade-sources.js +7 -2
  46. package/dist/memory-store.d.ts +19 -0
  47. package/dist/memory-store.js +8 -0
  48. package/dist/native-bindings.d.ts +7 -2
  49. package/dist/native-bindings.js +4 -0
  50. package/dist/project-tree.d.ts +50 -8
  51. package/dist/project-tree.js +121 -29
  52. package/dist/schema-registry.js +2 -2
  53. package/dist/setup.d.ts +76 -3
  54. package/dist/setup.js +313 -130
  55. package/dist/sqlite-driver.d.ts +134 -0
  56. package/dist/sqlite-driver.js +359 -0
  57. package/dist/tool-dispatch.d.ts +10 -1
  58. package/dist/tool-dispatch.js +37 -2
  59. package/dist/tools/agent-memory.js +10 -2
  60. package/dist/tools/app-plan.d.ts +11 -1
  61. package/dist/tools/app-plan.js +32 -6
  62. package/dist/tools/briefing-task-scope.d.ts +21 -2
  63. package/dist/tools/briefing-task-scope.js +97 -4
  64. package/dist/tools/decision-memory.js +9 -3
  65. package/dist/tools/dependency-check-signals.d.ts +7 -0
  66. package/dist/tools/dependency-check-signals.js +17 -2
  67. package/dist/tools/dependency-check.d.ts +1 -1
  68. package/dist/tools/dependency-check.js +1 -1
  69. package/dist/tools/dependency-health.d.ts +1 -1
  70. package/dist/tools/dependency-health.js +5 -0
  71. package/dist/tools/knowledge-gap-ranges.d.ts +1 -1
  72. package/dist/tools/knowledge-gap-ranges.js +1 -1
  73. package/dist/tools/knowledge-gap-scope.d.ts +2 -2
  74. package/dist/tools/project-scope.d.ts +45 -0
  75. package/dist/tools/project-scope.js +119 -0
  76. package/dist/tools/record-feedback.js +1 -1
  77. package/dist/tools/upgrade-impact-literals.js +1 -1
  78. package/dist/tools/upgrade-impact-report.d.ts +10 -0
  79. package/dist/tools/upgrade-impact-report.js +32 -2
  80. package/dist/tools/upgrade-impact.js +1 -1
  81. package/dist/tools/upgrade-planner-fixpaths.d.ts +40 -0
  82. package/dist/tools/upgrade-planner-fixpaths.js +0 -0
  83. package/dist/tools/upgrade-planner.d.ts +31 -3
  84. package/dist/tools/upgrade-planner.js +74 -19
  85. package/dist/tools/vulnerability-scan-concise.d.ts +13 -1
  86. package/dist/tools/vulnerability-scan-concise.js +15 -3
  87. package/dist/tools/vulnerability-scan-format.d.ts +58 -1
  88. package/dist/tools/vulnerability-scan-format.js +107 -11
  89. package/dist/tools/vulnerability-scan.d.ts +104 -13
  90. package/dist/tools/vulnerability-scan.js +27 -3
  91. package/dist/tools/what-should-i-know.d.ts +22 -2
  92. package/dist/tools/what-should-i-know.js +121 -11
  93. package/package.json +10 -31
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);
@@ -68,7 +69,7 @@ const SERVER_VERSION = (() => {
68
69
  return "0.0.0";
69
70
  }
70
71
  })();
71
- import { createDatabase, DEPENDENCY_GROUP_QUERY, FourDADatabase } from "./db.js";
72
+ import { createDatabase, DEPENDENCY_GROUP_QUERY, FourDADatabase, isUnreadableDbError, unreadableDbMessage, } from "./db.js";
72
73
  // =============================================================================
73
74
  // Server Setup
74
75
  // =============================================================================
@@ -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,11 +406,18 @@ 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) {
379
- const errorMessage = error instanceof Error ? error.message : String(error);
413
+ // A database that turns out damaged mid-session (SQLITE_CORRUPT on a
414
+ // page the open did not touch) gets the same guidance as one that
415
+ // fails to open, instead of the bare SQLite text.
416
+ const errorMessage = db && isUnreadableDbError(error)
417
+ ? unreadableDbMessage(db.dbPath, error)
418
+ : error instanceof Error
419
+ ? error.message
420
+ : String(error);
380
421
  return {
381
422
  content: [
382
423
  {
@@ -441,6 +482,7 @@ async function main() {
441
482
  refused unless MCP_AUTH_SECRET is set; authentication
442
483
  is then mandatory for every request.
443
484
  --setup Detect editors and write MCP config
485
+ --dry-run With --setup: print each file and entry, write nothing
444
486
  --doctor Validate database, bindings, and LLM providers
445
487
 
446
488
  Environment:
@@ -463,7 +505,7 @@ async function main() {
463
505
  }
464
506
  // Setup command: configure editors
465
507
  if (args.includes("--setup") || args.includes("setup")) {
466
- runSetup();
508
+ runSetup(args.includes("--dry-run"));
467
509
  return;
468
510
  }
469
511
  // Doctor command: validate installation health
@@ -509,6 +551,11 @@ async function main() {
509
551
  // -------------------------------------------------------------------------
510
552
  const dbPath = process.env.FOURDA_DB_PATH || undefined;
511
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
+ }
512
559
  if (validation.valid) {
513
560
  console.error(`[4DA] Database validated — ${validation.tables?.length ?? 0} tables found`);
514
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
  }
@@ -171,7 +171,10 @@ const ADDITIVE_HEADING = /^\W*(?:added|adds|new|new features?|features?|enhancem
171
171
  export function classifyHeading(heading) {
172
172
  if (/(?<!-)\bbreaking\b|⚠|incompatib|migration|\bmajor changes?\b/i.test(heading))
173
173
  return "breaking";
174
- if (/\bremov(?:e|ed|als?)\b|\bapi changes?\b/i.test(heading))
174
+ // "Removed", "Removals", "Deprecated and Removed", "API changes": a category.
175
+ // "`Cargo.lock` Removed from Tracking" (sqlx 0.9.0) is a news item about the
176
+ // repository, and its three paragraphs were flagged breaking.
177
+ if (/(?:^\W*|(?:\band|[&/,])\s*)remov(?:e|ed|als?)\b|\bapi changes?\b/i.test(heading))
175
178
  return "removal";
176
179
  if (/security|vulnerab/i.test(heading))
177
180
  return "security";
@@ -17,6 +17,13 @@
17
17
  * reference definitions are skipped: they are examples and URLs, not changes.
18
18
  */
19
19
  import { type EntryKind } from "./changelog-classify.js";
20
+ /**
21
+ * Version of the parser AND classifier output. A parsed changelog is cached
22
+ * for 7 days per package version, so a fix to either one stays invisible
23
+ * for a week unless this changes: bump it with every change to parseChangelog
24
+ * or changelog-classify.ts that can alter a section or an entry's kind.
25
+ */
26
+ export declare const CHANGELOG_PARSER_VERSION = 2;
20
27
  export interface ChangelogEntry {
21
28
  kind: EntryKind;
22
29
  text: string;
@@ -26,6 +33,14 @@ export interface ChangelogEntry {
26
33
  * under actix-web's "Removed" reads as harmless, to an agent and to a rater alike.
27
34
  */
28
35
  under?: string;
36
+ /**
37
+ * Bullets nested under this entry (feature lists, "Breaking change: ..."
38
+ * notes). They belong to the entry, not beside it: sqlx 0.9.0's Breaking
39
+ * section has 23 top-level bullets, and counting every nested one reported
40
+ * "83 entries flagged breaking" (2026-10-07). A nested bullet that is more
41
+ * severe than its parent raises the parent's kind.
42
+ */
43
+ details?: string[];
29
44
  }
30
45
  export interface ChangelogSection {
31
46
  version: string;
@@ -19,6 +19,13 @@
19
19
  */
20
20
  import { compareVersionPrecedence } from "./semver-precedence.js";
21
21
  import { classifyEntry, classifyHeading, sanitizeEntry } 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 const CHANGELOG_PARSER_VERSION = 2;
22
29
  const NAME_STEMS = ["changelog", "changes", "history", "releases", "release-notes", "release_notes", "releasenotes", "news"];
23
30
  const NAME_EXTS = ["", ".md", ".markdown", ".txt", ".rst"];
24
31
  /** True for a root-level basename that names a changelog (case-insensitive). */
@@ -71,7 +78,14 @@ function extractDate(text) {
71
78
  * heading. Exported for tests.
72
79
  */
73
80
  export function parseVersionHeading(raw) {
74
- const text = raw.trim().replace(/^\*\*|\*\*$/g, "");
81
+ // Inline HTML is markup, not heading text: stripe writes
82
+ // `## <a id="23-0-0"></a>23.0.0 - 2026-09-30`, and until 6.0.2 no stripe
83
+ // release was found (changelog.found: false, 2026-10-07).
84
+ const text = raw
85
+ .replace(/<\/?[A-Za-z][^<>]*>/g, " ")
86
+ .trim()
87
+ .replace(/^\*\*|\*\*$/g, "")
88
+ .trim();
75
89
  const m = HEADING.exec(text);
76
90
  if (!m)
77
91
  return null;
@@ -105,17 +119,37 @@ const PLAIN_LABEL = /^\s{0,3}([A-Za-z⚠][\w /&,'()⚠️-]{1,58}?)\s*:\s*$/;
105
119
  const HTML_LAYOUT = /^<\/?(?:details|summary|div|p|br|hr|img|picture|source|table|tr|td|th|thead|tbody|center|sup|sub)\b/i;
106
120
  /** A bullet that is only a label: "remove:", "**Breaking**:", "deps:". */
107
121
  const LABEL_BULLET = /^\W*([A-Za-z][\w -]{0,30}?)\W*:\W*$/;
122
+ const KIND_SEVERITY = { breaking: 3, security: 2, deprecation: 1, change: 0 };
123
+ /** Nested lines kept per entry, and the length of each. */
124
+ const MAX_DETAILS = 12;
125
+ const MAX_DETAIL_CHARS = 200;
108
126
  function flush(state) {
109
- if (state.pending && state.current) {
110
- const text = sanitizeEntry(state.pending.text);
111
- const under = state.pending.under;
112
- // Layout markup is not a change: actix-web 4 wraps pre-release notes in
113
- // `<details> <summary>...</summary>`, and both lines were counted as removals.
114
- if (text && !HTML_LAYOUT.test(text)) {
115
- state.current.entries.push({ kind: classifyEntry(text, state.pending.context), text, ...(under ? { under } : {}) });
116
- }
117
- }
127
+ const pending = state.pending;
118
128
  state.pending = null;
129
+ if (!pending || !state.current)
130
+ return;
131
+ const text = sanitizeEntry(pending.text);
132
+ // Layout markup is not a change: actix-web 4 wraps pre-release notes in
133
+ // `<details> <summary>...</summary>`, and both lines were counted as removals.
134
+ if (!text || HTML_LAYOUT.test(text))
135
+ return;
136
+ const kind = classifyEntry(text, pending.context);
137
+ if (pending.into) {
138
+ const parent = pending.into;
139
+ const details = (parent.details ??= []);
140
+ if (details.length < MAX_DETAILS)
141
+ details.push(sanitizeEntry(pending.text, MAX_DETAIL_CHARS));
142
+ // "Breaking change: adds a DB type parameter" under a plain "fix: RawSql
143
+ // lifetime issues" bullet makes the entry breaking.
144
+ if (KIND_SEVERITY[kind] > KIND_SEVERITY[parent.kind])
145
+ parent.kind = kind;
146
+ return;
147
+ }
148
+ const under = pending.under;
149
+ const entry = { kind, text, ...(under ? { under } : {}) };
150
+ state.current.entries.push(entry);
151
+ if (pending.outer)
152
+ state.parentEntry = entry;
119
153
  }
120
154
  /**
121
155
  * Headings that name a kind of change (keep-a-changelog and conventional-commit
@@ -135,6 +169,7 @@ function openSection(state, heading, level) {
135
169
  state.parentIndent = null;
136
170
  state.headingText = null;
137
171
  state.parentText = null;
172
+ state.parentEntry = null;
138
173
  }
139
174
  /** A heading or parent bullet as context text: emphasis and code marks dropped, one line, at most 80 characters. */
140
175
  function contextText(raw) {
@@ -156,6 +191,8 @@ function addLine(state, line) {
156
191
  if (label) {
157
192
  state.parentKind = classifyHeading(label[1]) ?? state.headingKind;
158
193
  state.parentText = contextText(label[1]);
194
+ // A label's children are entries of their own, under the label.
195
+ state.parentEntry = null;
159
196
  return;
160
197
  }
161
198
  // A breaking bullet lends "breaking" to its sub-points (date-fns 3.0: "**BREAKING**: Functions
@@ -167,7 +204,9 @@ function addLine(state, line) {
167
204
  }
168
205
  const context = !outer && state.parentKind ? state.parentKind : state.headingKind;
169
206
  const under = !outer && state.parentText ? state.parentText : state.headingText;
170
- state.pending = { text, context, indent, under };
207
+ if (outer)
208
+ state.parentEntry = null;
209
+ state.pending = { text, context, indent, under, outer, into: outer ? null : state.parentEntry };
171
210
  return;
172
211
  }
173
212
  if (line.trim() === "") {
@@ -182,7 +221,21 @@ function addLine(state, line) {
182
221
  state.pending.text += ` ${line.trim()}`; // wrapped paragraph line
183
222
  return;
184
223
  }
224
+ // An indented paragraph after a blank line continues the list item above it
225
+ // (stripe 23.0.0: "* ⚠️ Remove ErrorType export", blank, " Remove the ErrorType
226
+ // interface ..."): a detail of that entry, not an entry of its own.
227
+ if (!state.pending && state.parentEntry && /^\s{2,}\S/.test(line)) {
228
+ state.pending = {
229
+ text: line.trim(),
230
+ context: state.parentKind ?? state.headingKind,
231
+ indent: -2,
232
+ under: state.headingText,
233
+ into: state.parentEntry,
234
+ };
235
+ return;
236
+ }
185
237
  flush(state);
238
+ state.parentEntry = null;
186
239
  state.pending = { text: line, context: state.headingKind, indent: -1, under: state.headingText };
187
240
  }
188
241
  /** Parse a changelog into version sections, in document order (usually newest first). */
@@ -198,6 +251,7 @@ export function parseChangelog(text) {
198
251
  headingText: null,
199
252
  parentText: null,
200
253
  pending: null,
254
+ parentEntry: null,
201
255
  };
202
256
  let inFence = false;
203
257
  let inComment = false;
@@ -244,6 +298,7 @@ export function parseChangelog(text) {
244
298
  state.parentKind = null;
245
299
  state.parentIndent = null;
246
300
  state.parentText = null;
301
+ state.parentEntry = null;
247
302
  }
248
303
  continue;
249
304
  }
@@ -288,6 +343,7 @@ export function parseChangelog(text) {
288
343
  state.parentKind = null;
289
344
  state.parentIndent = null;
290
345
  state.parentText = null;
346
+ state.parentEntry = null;
291
347
  continue;
292
348
  }
293
349
  if (state.current)
@@ -13,7 +13,7 @@
13
13
  * claim a certainty the app itself does not have. The grade would not change
14
14
  * (unknown and runtime both get no discount), but `dev_scope_known` would lie.
15
15
  */
16
- import type Database from "better-sqlite3";
16
+ import type { SqliteDatabase } from "../sqlite-driver.js";
17
17
  import type { ResolvedDependency } from "./types.js";
18
18
  /**
19
19
  * A project path in the app's storage form: forward slashes, lowercased on
@@ -29,4 +29,4 @@ export declare function canonicalStoragePath(p: string): string;
29
29
  * dependencies are untouched: their manifest already says. Returns new
30
30
  * objects; never throws; a database without the table changes nothing.
31
31
  */
32
- export declare function applyInstanceDevScope(db: Database.Database | null, deps: ResolvedDependency[]): ResolvedDependency[];
32
+ export declare function applyInstanceDevScope(db: SqliteDatabase | null, deps: ResolvedDependency[]): ResolvedDependency[];
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Fix paths for vulnerable dependencies: WHICH version to move to, and HOW.
3
+ *
4
+ * Two defects found by the fix-path oracle (2026-10-10, 19 public repos;
5
+ * each recommendation applied in a copy, re-resolved, re-scanned):
6
+ *
7
+ * 1. The target could itself be vulnerable. It was the highest fix among the
8
+ * advisories that affect the installed version, so an advisory the
9
+ * installed version predates was never consulted: openssl 0.10.38 was sent
10
+ * to 0.10.79, which GHSA-phqj-4mhp-q6mq affects (0.10.50 up to 0.10.80).
11
+ * Now: the smallest published version at or above every per-advisory fix
12
+ * that NO advisory of the package affects (osv-ranges.ts).
13
+ * 2. Every transitive finding was "waiting on upstream", 3 wrong out of 3:
14
+ * minimist 1.2.5 (npm), braces 3.0.2 (pnpm) and mio 0.8.0 (Cargo) all had
15
+ * parents whose declared requirement already admitted the fix, and a
16
+ * lockfile refresh fixed each one. Now: the parents' requirements are
17
+ * read (from the lockfile, or from the parent's registry metadata for
18
+ * pnpm and Cargo) and a refresh is recommended when they all admit a
19
+ * clean version.
20
+ *
21
+ * Network: api.osv.dev (one /v1/query per vulnerable package) and the
22
+ * package's own registry; package names and versions only. Every lookup is
23
+ * cached, and any failure degrades to the old answer, never to a guess.
24
+ */
25
+ import type { LiveCache } from "./cache.js";
26
+ import type { RateLimiter } from "./rate-limiter.js";
27
+ import { type AdvisoryRanges, type CleanTarget } from "./osv-ranges.js";
28
+ import { type LockfileFormat } from "./lockfile-parents.js";
29
+ import type { OsvEcosystem } from "./types.js";
30
+ export interface CrateDepReq {
31
+ req: string;
32
+ optional: boolean;
33
+ }
34
+ export interface CratesSparse {
35
+ /** Versions not yanked. */
36
+ versions: string[];
37
+ /** version -> dependency crate name -> requirements (one per target/kind entry; dev-dependencies left out). */
38
+ reqs: Record<string, Record<string, CrateDepReq[]>>;
39
+ }
40
+ export declare class FixPathSources {
41
+ private cache;
42
+ private rateLimiter;
43
+ constructor(cache: LiveCache, rateLimiter: RateLimiter);
44
+ /** Every non-withdrawn OSV advisory for a package, as ranges; null when OSV could not be read. */
45
+ packageAdvisories(ecosystem: OsvEcosystem, name: string): Promise<AdvisoryRanges[] | null>;
46
+ /** Published, installable versions (not yanked); null when the registry could not be read or is not supported. */
47
+ publishedVersions(ecosystem: OsvEcosystem, name: string): Promise<string[] | null>;
48
+ /**
49
+ * The requirements `parent@parentVersion` declares on `dep`, from the
50
+ * registry: npm (pnpm lockfiles record none) and crates.io (Cargo.lock
51
+ * records none). Null when unknown; [] when the parent declares none.
52
+ */
53
+ parentRequirements(ecosystem: OsvEcosystem, parent: string, parentVersion: string, dep: string): Promise<string[] | null>;
54
+ private npmVersions;
55
+ /** A crate's sparse-index record: versions and per-version dependency requirements. */
56
+ cratesSparse(name: string): Promise<CratesSparse | null>;
57
+ private pypiVersions;
58
+ private goVersions;
59
+ }
60
+ export type FixTargetResult = CleanTarget;
61
+ /** The minimal clean target for one installed copy, or null when the advisories could not be read. */
62
+ export declare function cleanFixTarget(sources: FixPathSources, ecosystem: OsvEcosystem, name: string, perAdvisoryFixes: string[]): Promise<FixTargetResult | null>;
63
+ /**
64
+ * Verified targets for many installed copies at once, within a time budget:
65
+ * the copies that could not be checked in time (or offline) are simply
66
+ * absent from the result, and the caller says so. Keyed by `key`.
67
+ */
68
+ export declare function verifyFixTargets(sources: FixPathSources | null, items: Array<{
69
+ key: string;
70
+ ecosystem: OsvEcosystem;
71
+ name: string;
72
+ fixes: string[];
73
+ }>, budgetMs: number, concurrency?: number): Promise<Map<string, CleanTarget>>;
74
+ /** One clause for a target that is not the highest per-advisory fix, or null when it is. */
75
+ export declare function targetNote(clean: CleanTarget): string | null;
76
+ export interface RefreshVerdict {
77
+ /** True when every parent's requirement already admits a clean version. */
78
+ refreshFixes: boolean;
79
+ format: LockfileFormat;
80
+ /** The command, run in the lockfile's directory. */
81
+ command: string;
82
+ /** Parent requirements that do NOT admit a clean version (why it is waiting on upstream). */
83
+ blocking: Array<{
84
+ parent: string;
85
+ parentVersion: string | null;
86
+ requirement: string;
87
+ }>;
88
+ /** The version a refresh resolves to, when the registry's version list was read. */
89
+ resolvesTo: string | null;
90
+ }
91
+ /** The refresh command for one lockfile format. */
92
+ export declare function refreshCommand(format: LockfileFormat, name: string, installed: string, target: string): string;
93
+ /**
94
+ * Whether refreshing `lockfile` moves `name@installed` to a clean version
95
+ * without any parent changing its declared requirement. Null when the
96
+ * lockfile or a requirement cannot be read (the caller keeps "waiting on
97
+ * upstream", the conservative answer).
98
+ */
99
+ export declare function refreshVerdict(sources: FixPathSources, ecosystem: OsvEcosystem, lockfile: string, name: string, installed: string, target: string, advisories: AdvisoryRanges[] | null, published: string[] | null): Promise<RefreshVerdict | null>;
100
+ /**
101
+ * The locked crates `cargo update -p name@installed --precise target` cannot
102
+ * move by itself, as `crate@lockedVersion` specs to update first; null when it
103
+ * cannot be worked out (a registry read failed, or the walk is too large).
104
+ *
105
+ * Measured on nushell (2026-10-10): `cargo update -p openssl@0.10.38
106
+ * --precise 0.10.80` fails. 0.10.80 needs openssl-macros 0.1.1 -> syn 2 ->
107
+ * quote ^1.0.25, and quote 1.0.15 is locked by rstest, outside openssl's own
108
+ * dependency tree, so cargo keeps it and reports a conflict. `cargo update -p
109
+ * quote` first, then the precise update, works. Crates in the installed
110
+ * version's own tree may move with it (openssl-sys did) and are not listed.
111
+ *
112
+ * The walk follows the target's normal and build dependencies (optional ones
113
+ * only when the crate is already in the lockfile): a requirement a locked copy
114
+ * satisfies is reused; one whose compatibility line holds a locked copy that
115
+ * does not satisfy it names that copy; anything else is a new crate, walked
116
+ * at its newest matching version.
117
+ */
118
+ export declare function cargoPrecisePrerequisites(sources: FixPathSources, lockContent: string, name: string, installed: string, target: string): Promise<string[] | null>;