@4da/mcp-server 5.0.4 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/CHANGELOG.md +698 -0
  2. package/README.md +70 -39
  3. package/dist/build-staleness.d.ts +35 -0
  4. package/dist/build-staleness.js +95 -0
  5. package/dist/db.d.ts +53 -58
  6. package/dist/db.js +226 -299
  7. package/dist/doctor.js +27 -7
  8. package/dist/embeddings.js +7 -1
  9. package/dist/freshness-markers.d.ts +34 -0
  10. package/dist/freshness-markers.js +74 -0
  11. package/dist/gitignore.d.ts +37 -0
  12. package/dist/gitignore.js +130 -0
  13. package/dist/index.js +127 -44
  14. package/dist/live/bun-lockfile.d.ts +26 -0
  15. package/dist/live/bun-lockfile.js +123 -0
  16. package/dist/live/cargo-platform.d.ts +38 -0
  17. package/dist/live/cargo-platform.js +178 -0
  18. package/dist/live/changelog-classify.d.ts +41 -0
  19. package/dist/live/changelog-classify.js +207 -0
  20. package/dist/live/changelog.d.ts +64 -0
  21. package/dist/live/changelog.js +318 -0
  22. package/dist/live/crates-registry.js +3 -1
  23. package/dist/live/crates-versions.d.ts +47 -0
  24. package/dist/live/crates-versions.js +201 -0
  25. package/dist/live/dependency-set.d.ts +13 -0
  26. package/dist/live/dependency-set.js +95 -0
  27. package/dist/live/dev-scope.d.ts +32 -0
  28. package/dist/live/dev-scope.js +102 -0
  29. package/dist/live/file-signature.d.ts +18 -0
  30. package/dist/live/file-signature.js +43 -0
  31. package/dist/live/go-registry.js +3 -1
  32. package/dist/live/hn-fetcher.js +9 -3
  33. package/dist/live/index.d.ts +105 -2
  34. package/dist/live/index.js +266 -139
  35. package/dist/live/install-state.d.ts +43 -0
  36. package/dist/live/install-state.js +150 -0
  37. package/dist/live/js-lockfile-readers.d.ts +46 -0
  38. package/dist/live/js-lockfile-readers.js +181 -0
  39. package/dist/live/lockfile-parsers-pygo.d.ts +60 -0
  40. package/dist/live/lockfile-parsers-pygo.js +274 -0
  41. package/dist/live/lockfile-parsers.d.ts +69 -0
  42. package/dist/live/lockfile-parsers.js +311 -0
  43. package/dist/live/lockfile-types.d.ts +34 -0
  44. package/dist/live/lockfile-types.js +42 -0
  45. package/dist/live/maintenance.d.ts +33 -0
  46. package/dist/live/maintenance.js +37 -0
  47. package/dist/live/npm-packument.d.ts +47 -0
  48. package/dist/live/npm-packument.js +150 -0
  49. package/dist/live/npm-registry.js +67 -53
  50. package/dist/live/osv-scanner.d.ts +12 -1
  51. package/dist/live/osv-scanner.js +50 -15
  52. package/dist/live/package-archive.d.ts +49 -0
  53. package/dist/live/package-archive.js +186 -0
  54. package/dist/live/pypi-registry.js +3 -1
  55. package/dist/live/registry-health.d.ts +16 -0
  56. package/dist/live/registry-health.js +74 -0
  57. package/dist/live/release-metadata.d.ts +67 -0
  58. package/dist/live/release-metadata.js +16 -0
  59. package/dist/live/resolution.d.ts +43 -0
  60. package/dist/live/resolution.js +62 -0
  61. package/dist/live/semver-precedence.d.ts +30 -0
  62. package/dist/live/semver-precedence.js +83 -0
  63. package/dist/live/severity-scope.d.ts +57 -0
  64. package/dist/live/severity-scope.js +89 -0
  65. package/dist/live/types.d.ts +83 -0
  66. package/dist/live/upgrade-sources.d.ts +71 -0
  67. package/dist/live/upgrade-sources.js +198 -0
  68. package/dist/live/version-compare.d.ts +39 -0
  69. package/dist/live/version-compare.js +130 -0
  70. package/dist/live/version-resolver.d.ts +14 -1
  71. package/dist/live/version-resolver.js +93 -303
  72. package/dist/native-bindings.d.ts +19 -0
  73. package/dist/native-bindings.js +39 -0
  74. package/dist/project-manifests.d.ts +40 -0
  75. package/dist/project-manifests.js +193 -0
  76. package/dist/project-scanner.js +38 -48
  77. package/dist/project-tree.d.ts +41 -0
  78. package/dist/project-tree.js +110 -0
  79. package/dist/prompts.d.ts +28 -0
  80. package/dist/prompts.js +54 -0
  81. package/dist/schema-registry.d.ts +41 -29
  82. package/dist/schema-registry.js +98 -105
  83. package/dist/server-instructions.d.ts +11 -0
  84. package/dist/server-instructions.js +36 -0
  85. package/dist/tool-args.d.ts +28 -0
  86. package/dist/tool-args.js +93 -0
  87. package/dist/tool-dispatch.d.ts +1 -1
  88. package/dist/tool-dispatch.js +42 -8
  89. package/dist/tools/app-plan.d.ts +92 -0
  90. package/dist/tools/app-plan.js +155 -0
  91. package/dist/tools/briefing-stored-advisories.d.ts +20 -0
  92. package/dist/tools/briefing-stored-advisories.js +58 -0
  93. package/dist/tools/briefing-task-scope.d.ts +73 -0
  94. package/dist/tools/briefing-task-scope.js +197 -0
  95. package/dist/tools/briefing-wisdom.d.ts +31 -0
  96. package/dist/tools/briefing-wisdom.js +148 -0
  97. package/dist/tools/decision-enforcement.d.ts +0 -5
  98. package/dist/tools/decision-enforcement.js +0 -4
  99. package/dist/tools/decision-memory.js +18 -66
  100. package/dist/tools/dependency-check-signals.d.ts +74 -0
  101. package/dist/tools/dependency-check-signals.js +264 -0
  102. package/dist/tools/dependency-check.d.ts +98 -0
  103. package/dist/tools/dependency-check.js +295 -0
  104. package/dist/tools/dependency-health.js +74 -21
  105. package/dist/tools/developer-dna.d.ts +3 -0
  106. package/dist/tools/developer-dna.js +16 -15
  107. package/dist/tools/ecosystem-pulse.d.ts +6 -2
  108. package/dist/tools/ecosystem-pulse.js +53 -14
  109. package/dist/tools/get-actionable-signals.d.ts +26 -4
  110. package/dist/tools/get-actionable-signals.js +214 -251
  111. package/dist/tools/get-context.d.ts +2 -0
  112. package/dist/tools/get-context.js +9 -1
  113. package/dist/tools/get-relevant-content.d.ts +3 -1
  114. package/dist/tools/get-relevant-content.js +34 -3
  115. package/dist/tools/index.d.ts +4 -2
  116. package/dist/tools/index.js +4 -2
  117. package/dist/tools/install-drift-notes.d.ts +51 -0
  118. package/dist/tools/install-drift-notes.js +172 -0
  119. package/dist/tools/knowledge-gap-citations.d.ts +55 -0
  120. package/dist/tools/knowledge-gap-citations.js +132 -0
  121. package/dist/tools/knowledge-gap-exposure.d.ts +72 -0
  122. package/dist/tools/knowledge-gap-exposure.js +160 -0
  123. package/dist/tools/knowledge-gap-grading.d.ts +91 -0
  124. package/dist/tools/knowledge-gap-grading.js +157 -0
  125. package/dist/tools/knowledge-gap-installs.d.ts +65 -0
  126. package/dist/tools/knowledge-gap-installs.js +134 -0
  127. package/dist/tools/knowledge-gap-ranges.d.ts +51 -0
  128. package/dist/tools/knowledge-gap-ranges.js +146 -0
  129. package/dist/tools/knowledge-gap-scope.d.ts +63 -0
  130. package/dist/tools/knowledge-gap-scope.js +118 -0
  131. package/dist/tools/knowledge-gaps.d.ts +12 -3
  132. package/dist/tools/knowledge-gaps.js +172 -121
  133. package/dist/tools/package-presence.d.ts +24 -0
  134. package/dist/tools/package-presence.js +35 -0
  135. package/dist/tools/record-feedback.d.ts +0 -1
  136. package/dist/tools/record-feedback.js +7 -8
  137. package/dist/tools/signal-classifier.d.ts +25 -0
  138. package/dist/tools/signal-classifier.js +40 -0
  139. package/dist/tools/upgrade-impact-callsites.d.ts +52 -0
  140. package/dist/tools/upgrade-impact-callsites.js +226 -0
  141. package/dist/tools/upgrade-impact-literals.d.ts +45 -0
  142. package/dist/tools/upgrade-impact-literals.js +118 -0
  143. package/dist/tools/upgrade-impact-report.d.ts +80 -0
  144. package/dist/tools/upgrade-impact-report.js +178 -0
  145. package/dist/tools/upgrade-impact.d.ts +84 -0
  146. package/dist/tools/upgrade-impact.js +305 -0
  147. package/dist/tools/upgrade-planner.d.ts +41 -8
  148. package/dist/tools/upgrade-planner.js +201 -53
  149. package/dist/tools/vulnerability-scan-concise.d.ts +72 -0
  150. package/dist/tools/vulnerability-scan-concise.js +97 -0
  151. package/dist/tools/vulnerability-scan-format.d.ts +137 -0
  152. package/dist/tools/vulnerability-scan-format.js +276 -0
  153. package/dist/tools/vulnerability-scan.d.ts +338 -18
  154. package/dist/tools/vulnerability-scan.js +84 -125
  155. package/dist/tools/what-should-i-know.d.ts +86 -19
  156. package/dist/tools/what-should-i-know.js +277 -283
  157. package/dist/types.d.ts +4 -136
  158. package/package.json +35 -35
  159. package/dist/schemas/agent-memory.json +0 -49
  160. package/dist/schemas/decision-enforcement.json +0 -22
  161. package/dist/schemas/decision-memory.json +0 -54
  162. package/dist/schemas/dependency-health.json +0 -27
  163. package/dist/schemas/developer-dna.json +0 -219
  164. package/dist/schemas/ecosystem-pulse.json +0 -17
  165. package/dist/schemas/get-actionable-signals.json +0 -29
  166. package/dist/schemas/get-context.json +0 -17
  167. package/dist/schemas/get-relevant-content.json +0 -26
  168. package/dist/schemas/knowledge-gaps.json +0 -14
  169. package/dist/schemas/record-feedback.json +0 -24
  170. package/dist/schemas/upgrade-planner.json +0 -22
  171. package/dist/schemas/vulnerability-scan.json +0 -26
  172. package/dist/schemas/what-should-i-know.json +0 -19
@@ -2,9 +2,12 @@
2
2
  /**
3
3
  * upgrade_planner tool
4
4
  *
5
- * Ranked standalone upgrade recommendations for project dependencies.
6
- * Combines version freshness, vulnerability data, and deprecation status
7
- * to produce a prioritized upgrade plan.
5
+ * One brain (AD-049): when the 4DA app's database holds its persisted Upgrade
6
+ * Plan (current schema), the tool returns that plan's machine-readable work
7
+ * order (`provenance.mode: "app_plan"`, flagged `stale` past its horizon) —
8
+ * see ./app-plan.ts. Only without one does it run the standalone heuristic
9
+ * below: version freshness, vulnerability data and deprecation status from
10
+ * the local lockfiles (`provenance.mode: "standalone_heuristic"`).
8
11
  *
9
12
  * Honesty contract (Phase 0 hardening):
10
13
  * - Transitive CVEs are surfaced as `waiting_on_upstream` steps instead of
@@ -14,15 +17,26 @@
14
17
  * standalone plan, not the 4DA app's cross-project, version-confirmed plan.
15
18
  * - `projectPath` reports the directory set the dependencies were actually
16
19
  * resolved from, not whatever `process.cwd()` happens to be.
17
- * - When no vulnerability scan has run, the plan says so instead of silently
18
- * producing a CVE-blind ranking.
20
+ * - When no vulnerability scan has run, the planner runs one (bounded); if it
21
+ * cannot finish, the plan says it is CVE-blind instead of ranking silently.
22
+ * - When node_modules holds a different version than the lockfile, the row
23
+ * says so (`installedVersion`) and a reinstall is the step when nothing
24
+ * else about the lockfile's version needs changing.
19
25
  */
20
- import { compareSemver, maxSemver } from "../live/semver-utils.js";
26
+ import { isMaintenanceNotice } from "../live/maintenance.js";
27
+ import { computeSemverDistance } from "../live/semver-utils.js";
28
+ import { compareVersions, maxVersion, namesPackage } from "../live/version-compare.js";
29
+ import { formatAppPlan, readAppPlan } from "./app-plan.js";
30
+ import { dirsLabel, driftFor } from "./install-drift-notes.js";
31
+ import { emptyAnswerNote } from "./package-presence.js";
32
+ import { relativeDir } from "./vulnerability-scan-format.js";
33
+ /** How long a plan waits for its own OSV scan before answering CVE-blind (the scan keeps running). */
34
+ const PLANNER_SCAN_TIMEOUT_MS = 60_000;
21
35
  const PROVENANCE_NOTE = "Point-in-time heuristic from local lockfiles plus live registry/OSV lookups. " +
22
- "Not the 4DA desktop app's cross-project, version-confirmed plan.";
36
+ "Not the 4DA desktop app's cross-project, version-confirmed plan (none was available).";
23
37
  export const upgradePlannerTool = {
24
38
  name: "upgrade_planner",
25
- description: "Ranked standalone upgrade recommendations for dependencies — call before upgrading, adding, or auditing any dependency to pick the safest order. Prioritizes by vulnerability severity (direct AND transitive), deprecation, and version distance. Splits quick wins (patch/minor) from breaking changes (major) and from transitive CVEs waiting on upstream. Run vulnerability_scan first for a CVE-aware plan. Privacy: live mode may query public registries and OSV with package names and versions; it never sends source code.",
39
+ description: "Ranked dependency upgrade plan — call before upgrading, adding, or auditing any dependency to pick the safest order. When the 4DA desktop app has computed its plan, returns that plan's work order (provenance app_plan): per package the ecosystem, each installed version with its minimum clean target, upgrade type (patch/minor/major), the projects that hold it (direct/dev) and the mechanism (manifest_bump, lockfile_or_parent_update, mixed, no_fix); flagged stale past its freshness horizon. Otherwise falls back to a standalone plan from local lockfiles (provenance standalone_heuristic): the smallest version that fixes each vulnerability (direct and transitive), deprecation and version distance; it runs the OSV scan itself when none has run. `package` narrows either plan to one package. Privacy: the fallback may query public registries and OSV with package names and versions; it never sends source code.",
26
40
  inputSchema: {
27
41
  type: "object",
28
42
  properties: {
@@ -39,6 +53,10 @@ export const upgradePlannerTool = {
39
53
  enum: ["all", "low", "medium", "high", "critical"],
40
54
  description: "Only show upgrades at or above this risk level. Default: all",
41
55
  },
56
+ package: {
57
+ type: "string",
58
+ description: "Plan for this one package only (exact name, any ecosystem). Default: every dependency.",
59
+ },
42
60
  },
43
61
  },
44
62
  };
@@ -50,7 +68,26 @@ function severityToRisk(severities) {
50
68
  return "high";
51
69
  return "medium";
52
70
  }
53
- export async function executeUpgradePlanner(_db, params, liveIntel) {
71
+ /** Distance between the installed and target versions (patch/minor/major), or unknown. */
72
+ function upgradeTypeBetween(current, target) {
73
+ if (!current || !target)
74
+ return "unknown";
75
+ const distance = computeSemverDistance(current, target);
76
+ return !distance || distance.label === "up-to-date" ? "unknown" : distance.label;
77
+ }
78
+ function higherRisk(a, b) {
79
+ return RISK_LEVELS[b] > RISK_LEVELS[a] ? b : a;
80
+ }
81
+ export async function executeUpgradePlanner(db, params, liveIntel) {
82
+ // One brain: the app's plan wins whenever there is one, stale or not (a
83
+ // stale plan is returned flagged, never silently replaced by a heuristic).
84
+ const appPlan = readAppPlan(db);
85
+ if (appPlan.kind === "plan")
86
+ return formatAppPlan(appPlan, params);
87
+ const standalone = await executeStandalonePlanner(params, liveIntel);
88
+ return { ...standalone, appPlanUnavailable: appPlan.reason };
89
+ }
90
+ async function executeStandalonePlanner(params, liveIntel) {
54
91
  if (!liveIntel || !liveIntel.isInitialized()) {
55
92
  return {
56
93
  generatedAt: new Date().toISOString(),
@@ -65,21 +102,52 @@ export async function executeUpgradePlanner(_db, params, liveIntel) {
65
102
  provenance: { mode: "standalone_heuristic", note: PROVENANCE_NOTE },
66
103
  };
67
104
  }
105
+ // A long-lived server must not plan from the versions it read at startup:
106
+ // re-resolve if a lockfile (or node_modules) changed. Stat calls only.
107
+ liveIntel.refreshIfLockfilesChanged();
68
108
  const includeDev = params.include_dev ?? false;
109
+ const only = params.package?.trim();
110
+ const named = (name, ecosystem) => !only || namesPackage(only, name, ecosystem);
69
111
  let deps = liveIntel.getResolvedDeps();
70
112
  if (!includeDev)
71
113
  deps = deps.filter((d) => !d.isDev);
114
+ if (only)
115
+ deps = deps.filter((d) => named(d.name, d.ecosystem));
72
116
  // Fetch registry data (live lookups for direct deps; cached where fresh)
73
117
  const registryData = await liveIntel.fetchRegistryHealth(deps);
74
- // Vulnerability data from the last scan this session (no network here).
75
- const vulnResult = liveIntel.getVulnerabilities();
118
+ const drift = liveIntel.getInstallDrift();
119
+ const root = liveIntel.getProjectRoot() ?? process.cwd();
120
+ // Vulnerability data at the plan's scope, loaded here (bounded) when no scan
121
+ // has run. Telling the agent to "run vulnerability_scan first" made the first
122
+ // plan of every session CVE-blind (2026-10-03 agent eval). A plan that
123
+ // includes dev dependencies must not read a scan that left them out, so
124
+ // ensureVulnerabilities rescans at the wider scope when needed.
125
+ const vulnResult = (await liveIntel.ensureVulnerabilities(root, PLANNER_SCAN_TIMEOUT_MS, { includeDev })) ?? liveIntel.getVulnerabilities();
76
126
  const vulnerabilityDataAvailable = vulnResult !== null;
127
+ // Keyed by ecosystem + name + INSTANCE version. A name-only key attached every
128
+ // instance's CVEs to whichever row asked first: the direct anyhow 1.0.104 was
129
+ // charged with RUSTSEC-2026-0190 (fixed in 1.0.103 — it isn't affected) while
130
+ // the actually-vulnerable transitive anyhow 1.0.102 was hidden by the
131
+ // direct-name skip below. Scan entries are created per scanned instance, so
132
+ // the instance version is exact.
133
+ const instanceKey = (ecosystem, name, version) => `${ecosystem}\0${name}\0${version ?? "unknown"}`;
77
134
  const vulnsByPackage = new Map();
135
+ // "X is unmaintained" (RUSTSEC informational) is not a CVE and has nothing
136
+ // to upgrade to; it was counted as "1 known CVE" on paste, ttf-parser and
137
+ // five unic-* crates. Counted separately, never ranked.
138
+ const maintenanceNotices = new Set();
78
139
  if (vulnResult) {
79
140
  for (const v of vulnResult.vulnerabilities) {
80
- if (!vulnsByPackage.has(v.package))
81
- vulnsByPackage.set(v.package, []);
82
- vulnsByPackage.get(v.package).push({
141
+ if (!named(v.package, v.ecosystem))
142
+ continue;
143
+ if (isMaintenanceNotice(v)) {
144
+ maintenanceNotices.add(`${v.package}@${v.currentVersion}`);
145
+ continue;
146
+ }
147
+ const key = instanceKey(v.ecosystem, v.package, v.currentVersion);
148
+ if (!vulnsByPackage.has(key))
149
+ vulnsByPackage.set(key, []);
150
+ vulnsByPackage.get(key).push({
83
151
  severity: v.severity,
84
152
  vulnId: v.vulnId,
85
153
  summary: v.summary,
@@ -92,24 +160,36 @@ export async function executeUpgradePlanner(_db, params, liveIntel) {
92
160
  const recommendations = [];
93
161
  const directPackages = new Set();
94
162
  for (const dep of registryData) {
95
- directPackages.add(`${dep.ecosystem}\0${dep.name}`);
163
+ directPackages.add(instanceKey(dep.ecosystem, dep.name, dep.currentVersion));
96
164
  const reasons = [];
97
165
  let risk = "low";
98
- let targetVersion = dep.latestStableVersion || dep.latestVersion;
166
+ const latestStable = dep.latestStableVersion || dep.latestVersion;
167
+ let targetVersion = latestStable;
99
168
  let platformActive = true;
169
+ let fixable = false;
100
170
  // Check vulnerabilities
101
- const vulns = vulnsByPackage.get(dep.name);
171
+ const vulns = vulnsByPackage.get(instanceKey(dep.ecosystem, dep.name, dep.currentVersion));
102
172
  if (vulns && vulns.length > 0) {
103
- risk = severityToRisk(vulns.map((v) => v.severity));
104
- reasons.push(`${vulns.length} known CVE${vulns.length !== 1 ? "s" : ""} (${vulns.map((v) => v.vulnId).join(", ")})`);
173
+ reasons.push(`${vulns.length} known vulnerabilit${vulns.length !== 1 ? "ies" : "y"} (${vulns.map((v) => v.vulnId).join(", ")})`);
105
174
  if (vulns.every((v) => !v.platformActive)) {
175
+ // Code this host never compiles is not this host's exposure: listed,
176
+ // never ranked as high (openssl topped the plan on Windows).
106
177
  platformActive = false;
178
+ risk = "low";
107
179
  reasons.push("Affected code is gated behind a target not active on this platform");
108
180
  }
109
- // Use fixed version as target if available
181
+ else {
182
+ risk = severityToRisk(vulns.filter((v) => v.platformActive).map((v) => v.severity));
183
+ }
184
+ // The smallest version that fixes every fixable advisory: each
185
+ // fixedVersion is already the fix on the installed release line.
110
186
  const fixedVersions = vulns.map((v) => v.fixedVersion).filter((f) => Boolean(f));
111
- if (fixedVersions.length > 0 && !targetVersion) {
112
- targetVersion = maxSemver(fixedVersions);
187
+ if (fixedVersions.length > 0) {
188
+ targetVersion = maxVersion(fixedVersions, dep.ecosystem);
189
+ fixable = true;
190
+ if (fixedVersions.length < vulns.length) {
191
+ reasons.push(`${vulns.length - fixedVersions.length} of them have no published fix`);
192
+ }
113
193
  }
114
194
  }
115
195
  // Check deprecation
@@ -133,51 +213,89 @@ export async function executeUpgradePlanner(_db, params, liveIntel) {
133
213
  reasons.push(`${d.patch} patch${d.patch !== 1 ? "es" : ""} behind`);
134
214
  }
135
215
  }
136
- // Skip if no upgrade needed (no vuln, not deprecated, not behind => no reasons)
216
+ // Install drift: node_modules holds another version than this lockfile
217
+ // row, and until a reinstall the installed copy's advisories are the real
218
+ // exposure. Without this, a patched, up-to-date lockfile version had no
219
+ // reasons and the row was skipped, so a vulnerable installed copy never
220
+ // appeared in the plan at all.
221
+ const lockfileReasons = reasons.length;
222
+ const drifted = dep.ecosystem === "npm" ? driftFor(drift, dep.name, dep.currentVersion) : [];
223
+ let installedVersion;
224
+ let installFix;
225
+ if (drifted.length > 0) {
226
+ installedVersion = [...new Set(drifted.map((r) => r.installedVersion))].join(", ");
227
+ installFix = drifted[0].fix;
228
+ const where = dirsLabel(drifted.map((r) => r.dir), (d) => relativeDir(d, root));
229
+ reasons.push(`node_modules has ${installedVersion}, not the lockfile's ${dep.currentVersion} — run \`${installFix}\` in ${where}`);
230
+ for (const installed of new Set(drifted.map((r) => r.installedVersion))) {
231
+ const installedVulns = vulnsByPackage.get(instanceKey("npm", dep.name, installed));
232
+ if (!installedVulns || installedVulns.length === 0)
233
+ continue;
234
+ reasons.push(`The installed ${installed} has ${installedVulns.length} known CVE${installedVulns.length !== 1 ? "s" : ""} (${installedVulns.map((v) => v.vulnId).join(", ")})`);
235
+ risk = higherRisk(risk, severityToRisk(installedVulns.map((v) => v.severity)));
236
+ }
237
+ }
238
+ // Skip if no upgrade needed (no vuln, not deprecated, not behind, no drift => no reasons)
137
239
  if (reasons.length === 0)
138
240
  continue;
139
241
  // Never recommend a downgrade: when the current version is a prerelease
140
242
  // AHEAD of the latest stable (rsa 0.10.0-rc.18 vs stable 0.9.10), the
141
- // stable line is not an upgrade target. Fall back to the highest vuln
142
- // fix version that IS ahead of current, else no target ("review").
243
+ // stable line is not an upgrade target.
143
244
  if (targetVersion &&
144
245
  dep.currentVersion &&
145
- compareSemver(dep.currentVersion, targetVersion) >= 0) {
146
- const vulnFixesAhead = (vulns ?? [])
147
- .map((v) => v.fixedVersion)
148
- .filter((f) => Boolean(f))
149
- .filter((f) => compareSemver(f, dep.currentVersion) > 0);
150
- targetVersion = vulnFixesAhead.length > 0 ? maxSemver(vulnFixesAhead) : null;
246
+ compareVersions(dep.currentVersion, targetVersion, dep.ecosystem) >= 0) {
247
+ targetVersion = null;
151
248
  }
152
- const label = dep.versionsBehind?.label;
153
- const upgradeType = !label || label === "up-to-date" ? "unknown" : label;
249
+ const upgradeType = upgradeTypeBetween(dep.currentVersion, targetVersion);
250
+ // Drift is the only problem: the lockfile's version is the target, and
251
+ // reinstalling it is the whole step.
252
+ const reinstallOnly = drifted.length > 0 && lockfileReasons === 0;
253
+ const vulnerable = Boolean(vulns && vulns.length > 0);
254
+ const action = reinstallOnly
255
+ ? "reinstall"
256
+ : vulnerable && !platformActive
257
+ ? "not_built_on_this_host"
258
+ : vulnerable && !fixable
259
+ ? "no_fix_available"
260
+ : targetVersion
261
+ ? "upgrade_direct"
262
+ : "no_fix_available";
154
263
  recommendations.push({
155
264
  package: dep.name,
156
265
  ecosystem: dep.ecosystem,
157
266
  currentVersion: dep.currentVersion,
158
- targetVersion,
267
+ targetVersion: reinstallOnly ? dep.currentVersion : targetVersion,
268
+ ...(latestStable && latestStable !== targetVersion ? { latestVersion: latestStable } : {}),
159
269
  upgradeType,
160
270
  risk,
161
271
  reasons,
162
- breaking: dep.versionsBehind?.label === "major",
272
+ breaking: upgradeType === "major",
163
273
  isDev: dep.isDev,
164
274
  scope: "direct",
165
- action: "upgrade_direct",
275
+ action,
166
276
  platformActive,
277
+ ...(installedVersion ? { installedVersion, installFix } : {}),
167
278
  });
168
279
  }
169
280
  // Transitive vulnerable packages — previously counted in scan totals but
170
281
  // never recommended. Surfaced as waiting_on_upstream with honest labels.
282
+ // The skip is instance-aware: a transitive instance at a DIFFERENT version
283
+ // than a same-named direct dep is its own finding (anyhow 1.0.102 transitive
284
+ // beside anyhow 1.0.104 direct), not a duplicate of the direct row.
171
285
  if (vulnResult) {
172
286
  const transitive = new Map();
173
287
  for (const v of vulnResult.vulnerabilities) {
174
288
  if (v.isDirect)
175
289
  continue;
176
- if (directPackages.has(`${v.ecosystem}\0${v.package}`))
290
+ if (!named(v.package, v.ecosystem))
291
+ continue;
292
+ if (isMaintenanceNotice(v))
293
+ continue;
294
+ if (directPackages.has(instanceKey(v.ecosystem, v.package, v.currentVersion)))
177
295
  continue;
178
296
  if (!includeDev && v.isDev && v.devScopeKnown)
179
297
  continue;
180
- const key = `${v.ecosystem}\0${v.package}`;
298
+ const key = instanceKey(v.ecosystem, v.package, v.currentVersion);
181
299
  if (!transitive.has(key))
182
300
  transitive.set(key, []);
183
301
  transitive.get(key).push(v);
@@ -187,33 +305,44 @@ export async function executeUpgradePlanner(_db, params, liveIntel) {
187
305
  const fixedVersions = entries.map((e) => e.fixedVersion).filter((f) => Boolean(f));
188
306
  const platformActive = !entries.every((e) => !e.platformActive);
189
307
  const reasons = [
190
- `${entries.length} known CVE${entries.length !== 1 ? "s" : ""} (${entries.map((e) => e.vulnId).join(", ")})`,
308
+ `${entries.length} known vulnerabilit${entries.length !== 1 ? "ies" : "y"} (${entries.map((e) => e.vulnId).join(", ")})`,
191
309
  "Transitive dependency — not declared in your manifest; the fix arrives via a parent package update or a lockfile refresh",
192
310
  ];
193
311
  if (!platformActive) {
194
312
  reasons.push("Affected code is gated behind a target not active on this platform");
195
313
  }
314
+ const target = fixedVersions.length > 0 ? maxVersion(fixedVersions, first.ecosystem) : null;
196
315
  recommendations.push({
197
316
  package: first.package,
198
317
  ecosystem: first.ecosystem,
199
318
  currentVersion: first.currentVersion,
200
- targetVersion: fixedVersions.length > 0 ? maxSemver(fixedVersions) : null,
201
- upgradeType: "unknown",
202
- risk: severityToRisk(entries.map((e) => e.severity)),
319
+ targetVersion: target,
320
+ upgradeType: upgradeTypeBetween(first.currentVersion, target),
321
+ risk: platformActive ? severityToRisk(entries.filter((e) => e.platformActive).map((e) => e.severity)) : "low",
203
322
  reasons,
204
323
  breaking: false,
205
324
  isDev: entries.every((e) => e.isDev),
206
325
  scope: "transitive",
207
- action: "waiting_on_upstream",
326
+ action: !platformActive ? "not_built_on_this_host" : target ? "waiting_on_upstream" : "no_fix_available",
208
327
  platformActive,
209
328
  });
210
329
  }
211
330
  }
331
+ // Defensive dedupe: identical (ecosystem, package, version, scope) rows can
332
+ // still arrive when upstream dep lists carry duplicates. Keep the first.
333
+ const seenRecs = new Set();
334
+ const deduped = recommendations.filter((r) => {
335
+ const key = `${r.ecosystem}\0${r.package}\0${r.currentVersion ?? ""}\0${r.scope}`;
336
+ if (seenRecs.has(key))
337
+ return false;
338
+ seenRecs.add(key);
339
+ return true;
340
+ });
212
341
  // Filter by risk threshold
213
342
  const threshold = params.risk_threshold ?? "all";
214
343
  const filtered = threshold === "all"
215
- ? recommendations
216
- : recommendations.filter((r) => RISK_LEVELS[r.risk] >= RISK_LEVELS[threshold]);
344
+ ? deduped
345
+ : deduped.filter((r) => RISK_LEVELS[r.risk] >= RISK_LEVELS[threshold]);
217
346
  // Sort: critical > high > medium > low, then fixable-now (direct) before
218
347
  // waiting-on-upstream (transitive), then non-breaking first (quick wins).
219
348
  filtered.sort((a, b) => {
@@ -228,22 +357,41 @@ export async function executeUpgradePlanner(_db, params, liveIntel) {
228
357
  });
229
358
  const maxRecs = params.max_recommendations ?? 20;
230
359
  const limited = filtered.slice(0, maxRecs);
231
- const direct = limited.filter((r) => r.scope === "direct");
232
- const quickWins = direct.filter((r) => !r.breaking).length;
233
- const breakingChanges = direct.filter((r) => r.breaking).length;
234
- const waitingOnUpstream = limited.filter((r) => r.scope === "transitive").length;
360
+ const upgrades = limited.filter((r) => r.scope === "direct" && r.action === "upgrade_direct");
361
+ const quickWins = upgrades.filter((r) => !r.breaking).length;
362
+ const breakingChanges = upgrades.filter((r) => r.breaking).length;
363
+ const waitingOnUpstream = limited.filter((r) => r.action === "waiting_on_upstream").length;
364
+ const reinstalls = limited.filter((r) => r.action === "reinstall").length;
365
+ const noFix = limited.filter((r) => r.action === "no_fix_available").length;
366
+ const inactive = limited.filter((r) => r.action === "not_built_on_this_host").length;
235
367
  // Summary
236
368
  const parts = [];
237
- parts.push(`${limited.length} recommendation${limited.length !== 1 ? "s" : ""}`);
238
- if (direct.length > 0)
239
- parts.push(`${direct.length} fixable directly (${quickWins} quick win${quickWins !== 1 ? "s" : ""}, ${breakingChanges} breaking)`);
369
+ parts.push(filtered.length > limited.length
370
+ ? `Showing ${limited.length} of ${filtered.length} recommendations (raise max_recommendations for the rest)`
371
+ : `${limited.length} recommendation${limited.length !== 1 ? "s" : ""}`);
372
+ if (upgrades.length > 0)
373
+ parts.push(`${upgrades.length} upgradable directly (${quickWins} non-breaking, ${breakingChanges} crossing a major version)`);
374
+ if (reinstalls > 0)
375
+ parts.push(`${reinstalls} need only a reinstall (node_modules behind the lockfile)`);
240
376
  if (waitingOnUpstream > 0)
241
377
  parts.push(`${waitingOnUpstream} transitive, waiting on upstream`);
378
+ if (noFix > 0)
379
+ parts.push(`${noFix} vulnerable with no fixed release yet`);
380
+ if (inactive > 0)
381
+ parts.push(`${inactive} vulnerable only in code not built on this host`);
242
382
  const criticalCount = limited.filter((r) => r.risk === "critical").length;
243
383
  if (criticalCount > 0)
244
384
  parts.push(`${criticalCount} critical`);
385
+ if (maintenanceNotices.size > 0) {
386
+ parts.push(`${maintenanceNotices.size} unmaintained-package notice${maintenanceNotices.size !== 1 ? "s" : ""} not ranked (no fix exists; see vulnerability_scan maintenance_notices)`);
387
+ }
388
+ const nothing = vulnerabilityDataAvailable ? "nothing to upgrade (no known vulnerability, deprecation, or newer release of a direct dependency)" : "no vulnerability data to plan from";
389
+ if (only && limited.length === 0)
390
+ parts.push(emptyAnswerNote(only, liveIntel, includeDev, nothing));
245
391
  if (!vulnerabilityDataAvailable) {
246
- parts.push("CVE data not loaded — run vulnerability_scan first for a security-aware plan");
392
+ parts.push(liveIntel.isEnabled()
393
+ ? `No vulnerability data: the OSV scan did not finish within ${PLANNER_SCAN_TIMEOUT_MS / 1000}s or OSV was unreachable, so this plan is CVE-blind. Call again to use the scan once it completes`
394
+ : "No vulnerability data: offline mode (FOURDA_OFFLINE), so this plan is CVE-blind");
247
395
  }
248
396
  return {
249
397
  generatedAt: new Date().toISOString(),
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The default `vulnerability_scan` answer: one row per vulnerable package
3
+ * version, ranked, bounded, and saying what it left out.
4
+ *
5
+ * It used to list one row per advisory. Measured 2026-10-03 on the pre-publish
6
+ * benchmark: nodejs-goof (290 advisories over 137 package versions) came to
7
+ * ~25k tokens and hyper-term with devDependencies to ~26k, at or past the 25k
8
+ * an MCP answer may use in Claude Code before it is cut off, on exactly the
9
+ * projects where the scan matters most. An agent acts per package ("upgrade
10
+ * ejs 1.0.0 to 3.1.10"), so a row is a package version: its graded severity,
11
+ * the smallest version that fixes every fixable advisory on it, and the
12
+ * advisory count with the first ids. Every advisory is still counted in
13
+ * `by_severity`; `response_format: "detailed"` lists them one by one.
14
+ */
15
+ import { type SeverityTier } from "../live/severity-scope.js";
16
+ import type { formatScanResult } from "./vulnerability-scan-format.js";
17
+ type FullScan = ReturnType<typeof formatScanResult>;
18
+ type FormattedVuln = FullScan["vulnerabilities"][number];
19
+ export interface PackageRow {
20
+ package: string;
21
+ current_version: string;
22
+ severity: SeverityTier;
23
+ advisories: number;
24
+ ids: string[];
25
+ fixed_version: string | null;
26
+ unfixed_advisories?: number;
27
+ is_direct: boolean | null;
28
+ is_dev?: true;
29
+ source_dirs: string[];
30
+ summary: string;
31
+ }
32
+ /** One row per package version: worst graded severity, the fix that clears every fixable advisory. */
33
+ export declare function groupByPackage(rows: FormattedVuln[]): PackageRow[];
34
+ export declare function conciseScanResult(full: FullScan): {
35
+ scan_time: string;
36
+ project_root: string;
37
+ host_platform: string;
38
+ ecosystems_scanned: string[];
39
+ total_dependencies_scanned: number;
40
+ total_vulnerable_packages: number;
41
+ by_severity: import("../live/severity-scope.js").SeverityCounts;
42
+ vulnerable_packages: PackageRow[];
43
+ vulnerable_packages_note?: string | undefined;
44
+ recommendations: string[];
45
+ recommendations_note?: string | undefined;
46
+ not_listed: {
47
+ platform_inactive: {
48
+ count: number;
49
+ packages: string[];
50
+ };
51
+ maintenance_notices: {
52
+ count: number;
53
+ packages: string[];
54
+ };
55
+ install_drift: {
56
+ count: number;
57
+ vulnerable_installed: Record<string, unknown>[];
58
+ };
59
+ note: string;
60
+ };
61
+ _meta: {
62
+ source: string;
63
+ osv_cached: boolean;
64
+ offline: boolean;
65
+ relevance: string;
66
+ severity_rule: string;
67
+ lockfiles_read: number;
68
+ resolved_at: string | null;
69
+ privacy: string;
70
+ };
71
+ };
72
+ export {};
@@ -0,0 +1,97 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * The default `vulnerability_scan` answer: one row per vulnerable package
4
+ * version, ranked, bounded, and saying what it left out.
5
+ *
6
+ * It used to list one row per advisory. Measured 2026-10-03 on the pre-publish
7
+ * benchmark: nodejs-goof (290 advisories over 137 package versions) came to
8
+ * ~25k tokens and hyper-term with devDependencies to ~26k, at or past the 25k
9
+ * an MCP answer may use in Claude Code before it is cut off, on exactly the
10
+ * projects where the scan matters most. An agent acts per package ("upgrade
11
+ * ejs 1.0.0 to 3.1.10"), so a row is a package version: its graded severity,
12
+ * the smallest version that fixes every fixable advisory on it, and the
13
+ * advisory count with the first ids. Every advisory is still counted in
14
+ * `by_severity`; `response_format: "detailed"` lists them one by one.
15
+ */
16
+ import { compareVersions, maxVersion } from "../live/version-compare.js";
17
+ import { SEVERITY_RANK } from "../live/severity-scope.js";
18
+ const MAX_PACKAGES = 40;
19
+ const MAX_RECOMMENDATIONS = 25;
20
+ const IDS_PER_ROW = 3;
21
+ /** One row per package version: worst graded severity, the fix that clears every fixable advisory. */
22
+ export function groupByPackage(rows) {
23
+ const groups = new Map();
24
+ for (const v of rows) {
25
+ const key = `${v.ecosystem}\0${v.package}\0${v.current_version}`;
26
+ groups.set(key, [...(groups.get(key) ?? []), v]);
27
+ }
28
+ const out = [];
29
+ for (const vulns of groups.values()) {
30
+ const worst = vulns.reduce((a, b) => (SEVERITY_RANK[b.severity] > SEVERITY_RANK[a.severity] ? b : a));
31
+ const fixes = vulns.map((v) => v.fixed_version).filter((f) => Boolean(f));
32
+ const unfixed = vulns.length - fixes.length;
33
+ const fixed = fixes.length > 0 ? maxVersion(fixes, worst.ecosystem) : null;
34
+ // Unknown (null) only when no advisory row knows; any direct row makes it direct.
35
+ const direct = vulns.some((v) => v.is_direct === true) ? true : vulns.some((v) => v.is_direct === null) ? null : false;
36
+ out.push({
37
+ package: worst.package,
38
+ current_version: worst.current_version,
39
+ severity: worst.severity,
40
+ advisories: vulns.length,
41
+ ids: vulns.slice(0, IDS_PER_ROW).map((v) => v.vuln_id),
42
+ fixed_version: fixed,
43
+ ...(unfixed > 0 ? { unfixed_advisories: unfixed } : {}),
44
+ is_direct: direct,
45
+ ...(vulns.every((v) => v.is_dev) ? { is_dev: true } : {}),
46
+ source_dirs: [...new Set(vulns.flatMap((v) => v.source_dirs ?? []))],
47
+ summary: worst.summary.length > 120 ? `${worst.summary.slice(0, 117)}...` : worst.summary,
48
+ });
49
+ }
50
+ // Worst first; at equal severity a direct dependency (you can bump it) first, then the busiest.
51
+ return out.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] ||
52
+ Number(b.is_direct === true) - Number(a.is_direct === true) ||
53
+ b.advisories - a.advisories ||
54
+ a.package.localeCompare(b.package) ||
55
+ compareVersions(a.current_version, b.current_version, "npm"));
56
+ }
57
+ export function conciseScanResult(full) {
58
+ const names = (rows) => [...new Set(rows.map((v) => `${v.package}@${v.current_version}`))];
59
+ const vulnerableDrift = full.install_drift.filter((row) => row.vulnerable_installed === true);
60
+ const packages = groupByPackage(full.vulnerabilities);
61
+ const listed = packages.slice(0, MAX_PACKAGES);
62
+ const recommendations = full.recommendations.slice(0, MAX_RECOMMENDATIONS);
63
+ const omittedPackages = packages.length - listed.length;
64
+ const omittedRecommendations = full.recommendations.length - recommendations.length;
65
+ return {
66
+ scan_time: full.scan_time,
67
+ project_root: full.project_root,
68
+ host_platform: full.host_platform,
69
+ ecosystems_scanned: full.ecosystems_scanned,
70
+ total_dependencies_scanned: full.total_dependencies_scanned,
71
+ total_vulnerable_packages: full.total_vulnerable_packages,
72
+ // Every listed advisory, before grouping and trimming.
73
+ by_severity: full.by_severity,
74
+ vulnerable_packages: listed,
75
+ ...(omittedPackages > 0
76
+ ? { vulnerable_packages_note: `Showing the ${listed.length} most severe of ${packages.length} vulnerable package versions; pass severity_filter (e.g. "high") or response_format: "detailed" for the rest.` }
77
+ : {}),
78
+ recommendations,
79
+ ...(omittedRecommendations > 0 ? { recommendations_note: `Showing ${recommendations.length} of ${full.recommendations.length}.` } : {}),
80
+ not_listed: {
81
+ platform_inactive: { count: full.platform_inactive_vulnerabilities.length, packages: names(full.platform_inactive_vulnerabilities) },
82
+ maintenance_notices: { count: full.maintenance_notices.length, packages: names(full.maintenance_notices) },
83
+ install_drift: { count: full.install_drift.length, vulnerable_installed: vulnerableDrift },
84
+ note: 'Pass response_format: "detailed" for one row per advisory, these rows, advisory references and resolution provenance.',
85
+ },
86
+ _meta: {
87
+ source: full._meta.source,
88
+ osv_cached: full._meta.osv_cached,
89
+ offline: full._meta.offline,
90
+ relevance: full._meta.relevance,
91
+ severity_rule: "`severity` is scope-adjusted (transitive caps critical at high; dev-only drops one level) and is the worst of the package version's advisories; detailed rows carry each advisory's own `advisory_severity`.",
92
+ lockfiles_read: full._meta.resolution.lockfiles.length,
93
+ resolved_at: full._meta.resolution.resolved_at,
94
+ privacy: full._meta.privacy,
95
+ },
96
+ };
97
+ }