@4da/mcp-server 6.0.2 → 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 (91) hide show
  1. package/CHANGELOG.md +265 -0
  2. package/README.md +13 -10
  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.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/sqlite-driver.d.ts +134 -0
  54. package/dist/sqlite-driver.js +359 -0
  55. package/dist/tool-dispatch.d.ts +8 -1
  56. package/dist/tool-dispatch.js +17 -2
  57. package/dist/tools/agent-memory.js +10 -2
  58. package/dist/tools/app-plan.d.ts +11 -1
  59. package/dist/tools/app-plan.js +32 -6
  60. package/dist/tools/briefing-task-scope.d.ts +21 -2
  61. package/dist/tools/briefing-task-scope.js +97 -4
  62. package/dist/tools/decision-memory.js +9 -3
  63. package/dist/tools/dependency-check-signals.d.ts +7 -0
  64. package/dist/tools/dependency-check-signals.js +17 -2
  65. package/dist/tools/dependency-check.d.ts +1 -1
  66. package/dist/tools/dependency-check.js +1 -1
  67. package/dist/tools/dependency-health.d.ts +1 -1
  68. package/dist/tools/dependency-health.js +5 -0
  69. package/dist/tools/knowledge-gap-ranges.d.ts +1 -1
  70. package/dist/tools/knowledge-gap-ranges.js +1 -1
  71. package/dist/tools/knowledge-gap-scope.d.ts +2 -2
  72. package/dist/tools/project-scope.d.ts +45 -0
  73. package/dist/tools/project-scope.js +119 -0
  74. package/dist/tools/record-feedback.js +1 -1
  75. package/dist/tools/upgrade-impact-literals.js +1 -1
  76. package/dist/tools/upgrade-impact-report.d.ts +10 -0
  77. package/dist/tools/upgrade-impact-report.js +32 -2
  78. package/dist/tools/upgrade-impact.js +1 -1
  79. package/dist/tools/upgrade-planner-fixpaths.d.ts +40 -0
  80. package/dist/tools/upgrade-planner-fixpaths.js +0 -0
  81. package/dist/tools/upgrade-planner.d.ts +31 -3
  82. package/dist/tools/upgrade-planner.js +74 -19
  83. package/dist/tools/vulnerability-scan-concise.d.ts +13 -1
  84. package/dist/tools/vulnerability-scan-concise.js +15 -3
  85. package/dist/tools/vulnerability-scan-format.d.ts +58 -1
  86. package/dist/tools/vulnerability-scan-format.js +107 -11
  87. package/dist/tools/vulnerability-scan.d.ts +104 -13
  88. package/dist/tools/vulnerability-scan.js +27 -3
  89. package/dist/tools/what-should-i-know.d.ts +22 -2
  90. package/dist/tools/what-should-i-know.js +121 -11
  91. package/package.json +5 -27
@@ -30,6 +30,8 @@ export interface UpgradePlannerParams {
30
30
  max_recommendations?: number;
31
31
  risk_threshold?: "all" | "low" | "medium" | "high" | "critical";
32
32
  package?: string;
33
+ /** Project directory to plan for; "*" for every project. Default: the server's project, when it is one. */
34
+ project_path?: string;
33
35
  }
34
36
  interface UpgradeRecommendation {
35
37
  package: string;
@@ -53,19 +55,37 @@ interface UpgradeRecommendation {
53
55
  /** Whether this package is declared in a manifest (direct) or only present via the lockfile (transitive). */
54
56
  scope: "direct" | "transitive";
55
57
  /**
56
- * Direct deps you can bump yourself; transitive fixes arrive via a parent
57
- * update or lockfile refresh; `reinstall` = the lockfile's version is fine
58
+ * Direct deps you can bump yourself; `lockfile_refresh` = transitive, and
59
+ * every parent's declared requirement already admits a clean version, so
60
+ * `refreshCommands` fixes it; `waiting_on_upstream` = transitive, and a
61
+ * parent's requirement excludes every clean version (or could not be
62
+ * read); `reinstall` = the lockfile's version is fine
58
63
  * but node_modules holds another one, so the fix is `installFix`;
59
64
  * `no_fix_available` = vulnerable with no fixed release to move to;
60
65
  * `not_built_on_this_host` = every advisory is against code this host never compiles.
61
66
  */
62
- action: "upgrade_direct" | "waiting_on_upstream" | "reinstall" | "no_fix_available" | "not_built_on_this_host";
67
+ action: "upgrade_direct" | "lockfile_refresh" | "waiting_on_upstream" | "reinstall" | "no_fix_available" | "not_built_on_this_host";
68
+ /**
69
+ * For a vulnerable package: whether the target was checked against EVERY
70
+ * known advisory of the package (`all_advisories`), or only against the
71
+ * advisories of the installed version because the lookups failed or ran
72
+ * out of time (`installed_advisories_only`; the target may then be
73
+ * affected by an advisory the installed version predates).
74
+ */
75
+ fixPathChecked?: "all_advisories" | "installed_advisories_only";
76
+ /** lockfile_refresh: the command to run, per lockfile directory. */
77
+ refreshCommands?: Array<{
78
+ dir: string;
79
+ command: string;
80
+ }>;
63
81
  /** False when the dependency is gated behind a target spec not active on this host (label, never suppressed). */
64
82
  platformActive: boolean;
65
83
  /** npm: the version node_modules holds, when it differs from `currentVersion` (the lockfile's). */
66
84
  installedVersion?: string;
67
85
  /** npm: the command that reinstalls from the lockfile, when `installedVersion` is set. */
68
86
  installFix?: string;
87
+ /** No lockfile: the manifest's declared requirement; `currentVersion` is its floor, not an install. */
88
+ declaredRange?: string;
69
89
  }
70
90
  interface UpgradePlanResult {
71
91
  generatedAt: string;
@@ -76,6 +96,8 @@ interface UpgradePlanResult {
76
96
  quickWins: number;
77
97
  breakingChanges: number;
78
98
  waitingOnUpstream: number;
99
+ /** Transitive steps a lockfile refresh fixes (every parent already admits a clean version). */
100
+ lockfileRefreshes: number;
79
101
  /** False when no vulnerability scan has run this session — the ranking is then CVE-blind and says so. */
80
102
  vulnerabilityDataAvailable: boolean;
81
103
  provenance: {
@@ -84,6 +106,8 @@ interface UpgradePlanResult {
84
106
  };
85
107
  /** Why the 4DA app's own plan was not used (no database, no snapshot, other schema). */
86
108
  appPlanUnavailable?: string;
109
+ /** The project the plan covers, or null when it is not narrowed to one. */
110
+ projectScope?: string | null;
87
111
  }
88
112
  export declare const upgradePlannerTool: {
89
113
  name: string;
@@ -108,6 +132,10 @@ export declare const upgradePlannerTool: {
108
132
  type: string;
109
133
  description: string;
110
134
  };
135
+ project_path: {
136
+ type: string;
137
+ description: string;
138
+ };
111
139
  };
112
140
  };
113
141
  };
@@ -30,13 +30,16 @@ import { formatAppPlan, readAppPlan } from "./app-plan.js";
30
30
  import { dirsLabel, driftFor } from "./install-drift-notes.js";
31
31
  import { emptyAnswerNote } from "./package-presence.js";
32
32
  import { relativeDir } from "./vulnerability-scan-format.js";
33
+ import { liveIntelFor, resolveProjectScope } from "./project-scope.js";
34
+ import { verifyFixPaths } from "./upgrade-planner-fixpaths.js";
35
+ import { discoveryNote } from "../project-tree.js";
33
36
  /** How long a plan waits for its own OSV scan before answering CVE-blind (the scan keeps running). */
34
37
  const PLANNER_SCAN_TIMEOUT_MS = 60_000;
35
38
  const PROVENANCE_NOTE = "Point-in-time heuristic from local lockfiles plus live registry/OSV lookups. " +
36
39
  "Not the 4DA desktop app's cross-project, version-confirmed plan (none was available).";
37
40
  export const upgradePlannerTool = {
38
41
  name: "upgrade_planner",
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.",
42
+ description: "Ranked dependency upgrade plan for one project — call before upgrading, adding, or auditing any dependency to pick the safest order. `project_path` picks the project (default: the one the server was started in; \"*\" for every project). 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), checked against every known advisory of the package, whether a lockfile refresh already reaches it (lockfile_refresh with the command) or a parent blocks it (waiting_on_upstream), 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.",
40
43
  inputSchema: {
41
44
  type: "object",
42
45
  properties: {
@@ -57,10 +60,25 @@ export const upgradePlannerTool = {
57
60
  type: "string",
58
61
  description: "Plan for this one package only (exact name, any ecosystem). Default: every dependency.",
59
62
  },
63
+ project_path: {
64
+ type: "string",
65
+ description: "Project directory to plan for: the app's plan is narrowed to the steps (and installed lines) that touch it; the standalone plan resolves that project's lockfiles. \"*\" for every project the app tracks. Default: the project the server was started in, when that directory is a project.",
66
+ },
60
67
  },
61
68
  },
62
69
  };
63
70
  const RISK_LEVELS = { low: 0, medium: 1, high: 2, critical: 3 };
71
+ /** critical > high > medium > low, then fixable now (direct) before transitive, then non-breaking first (quick wins). */
72
+ function planOrder(a, b) {
73
+ const riskDiff = RISK_LEVELS[b.risk] - RISK_LEVELS[a.risk];
74
+ if (riskDiff !== 0)
75
+ return riskDiff;
76
+ if (a.scope !== b.scope)
77
+ return a.scope === "direct" ? -1 : 1;
78
+ if (a.breaking !== b.breaking)
79
+ return a.breaking ? 1 : -1;
80
+ return 0;
81
+ }
64
82
  function severityToRisk(severities) {
65
83
  if (severities.includes("critical"))
66
84
  return "critical";
@@ -79,13 +97,23 @@ function higherRisk(a, b) {
79
97
  return RISK_LEVELS[b] > RISK_LEVELS[a] ? b : a;
80
98
  }
81
99
  export async function executeUpgradePlanner(db, params, liveIntel) {
100
+ const scope = resolveProjectScope(params.project_path);
101
+ if (scope.kind === "error")
102
+ throw new Error(scope.error);
103
+ const scopePath = scope.kind === "project" ? scope.path : null;
82
104
  // One brain: the app's plan wins whenever there is one, stale or not (a
83
105
  // stale plan is returned flagged, never silently replaced by a heuristic).
106
+ // It covers every project the app tracks, so it is narrowed to this one.
84
107
  const appPlan = readAppPlan(db);
85
108
  if (appPlan.kind === "plan")
86
- return formatAppPlan(appPlan, params);
87
- const standalone = await executeStandalonePlanner(params, liveIntel);
88
- return { ...standalone, appPlanUnavailable: appPlan.reason };
109
+ return formatAppPlan(appPlan, params, scopePath);
110
+ // The live layer already covers the server's own project; only an explicit
111
+ // project_path elsewhere resolves another tree.
112
+ const scoped = scope.kind === "project" && scope.source === "argument"
113
+ ? liveIntelFor(liveIntel, db, scope)
114
+ : liveIntel;
115
+ const standalone = await executeStandalonePlanner(params, scoped);
116
+ return { ...standalone, appPlanUnavailable: appPlan.reason, projectScope: scopePath ?? standalone.projectPath };
89
117
  }
90
118
  async function executeStandalonePlanner(params, liveIntel) {
91
119
  if (!liveIntel || !liveIntel.isInitialized()) {
@@ -98,6 +126,7 @@ async function executeStandalonePlanner(params, liveIntel) {
98
126
  quickWins: 0,
99
127
  breakingChanges: 0,
100
128
  waitingOnUpstream: 0,
129
+ lockfileRefreshes: 0,
101
130
  vulnerabilityDataAvailable: false,
102
131
  provenance: { mode: "standalone_heuristic", note: PROVENANCE_NOTE },
103
132
  };
@@ -159,6 +188,8 @@ async function executeStandalonePlanner(params, liveIntel) {
159
188
  // Build recommendations — direct deps (registry-informed)
160
189
  const recommendations = [];
161
190
  const directPackages = new Set();
191
+ // Per vulnerable step: the per-advisory fixes and where it is pinned, for fix-path verification.
192
+ const fixPathInputs = new Map();
162
193
  for (const dep of registryData) {
163
194
  directPackages.add(instanceKey(dep.ecosystem, dep.name, dep.currentVersion));
164
195
  const reasons = [];
@@ -201,7 +232,15 @@ async function executeStandalonePlanner(params, liveIntel) {
201
232
  // Check version distance
202
233
  if (dep.versionsBehind) {
203
234
  const d = dep.versionsBehind;
204
- if (d.label === "major") {
235
+ if (d.label === "major" && d.major === 0) {
236
+ // A 0.x bump: breaking under caret rules, though the major number is unchanged.
237
+ const n = d.minor > 0 ? d.minor : d.patch;
238
+ const unit = d.minor > 0 ? "minor version" : "patch";
239
+ reasons.push(`${n} 0.x ${unit}${n !== 1 ? (unit === "patch" ? "es" : "s") : ""} behind (breaking: below 1.0 a ${unit === "patch" ? "0.0.x patch" : "minor"} bump is a major one)`);
240
+ if (risk === "low")
241
+ risk = "medium";
242
+ }
243
+ else if (d.label === "major") {
205
244
  reasons.push(`${d.major} major version${d.major !== 1 ? "s" : ""} behind`);
206
245
  if (risk === "low")
207
246
  risk = "medium";
@@ -238,6 +277,9 @@ async function executeStandalonePlanner(params, liveIntel) {
238
277
  // Skip if no upgrade needed (no vuln, not deprecated, not behind, no drift => no reasons)
239
278
  if (reasons.length === 0)
240
279
  continue;
280
+ if (dep.declaredRange) {
281
+ reasons.push(`No lockfile: ${dep.currentVersion} is the floor of the declared range "${dep.declaredRange}", not an installed version`);
282
+ }
241
283
  // Never recommend a downgrade: when the current version is a prerelease
242
284
  // AHEAD of the latest stable (rsa 0.10.0-rc.18 vs stable 0.9.10), the
243
285
  // stable line is not an upgrade target.
@@ -260,7 +302,7 @@ async function executeStandalonePlanner(params, liveIntel) {
260
302
  : targetVersion
261
303
  ? "upgrade_direct"
262
304
  : "no_fix_available";
263
- recommendations.push({
305
+ const rec = {
264
306
  package: dep.name,
265
307
  ecosystem: dep.ecosystem,
266
308
  currentVersion: dep.currentVersion,
@@ -275,7 +317,12 @@ async function executeStandalonePlanner(params, liveIntel) {
275
317
  action,
276
318
  platformActive,
277
319
  ...(installedVersion ? { installedVersion, installFix } : {}),
278
- });
320
+ ...(dep.declaredRange ? { declaredRange: dep.declaredRange } : {}),
321
+ };
322
+ recommendations.push(rec);
323
+ if (fixable && !reinstallOnly && action === "upgrade_direct" && vulns) {
324
+ fixPathInputs.set(rec, { fixes: vulns.map((v) => v.fixedVersion).filter((f) => Boolean(f)), sourceDirs: [] });
325
+ }
279
326
  }
280
327
  // Transitive vulnerable packages — previously counted in scan totals but
281
328
  // never recommended. Surfaced as waiting_on_upstream with honest labels.
@@ -312,7 +359,7 @@ async function executeStandalonePlanner(params, liveIntel) {
312
359
  reasons.push("Affected code is gated behind a target not active on this platform");
313
360
  }
314
361
  const target = fixedVersions.length > 0 ? maxVersion(fixedVersions, first.ecosystem) : null;
315
- recommendations.push({
362
+ const rec = {
316
363
  package: first.package,
317
364
  ecosystem: first.ecosystem,
318
365
  currentVersion: first.currentVersion,
@@ -325,7 +372,11 @@ async function executeStandalonePlanner(params, liveIntel) {
325
372
  scope: "transitive",
326
373
  action: !platformActive ? "not_built_on_this_host" : target ? "waiting_on_upstream" : "no_fix_available",
327
374
  platformActive,
328
- });
375
+ };
376
+ recommendations.push(rec);
377
+ if (rec.action === "waiting_on_upstream") {
378
+ fixPathInputs.set(rec, { fixes: fixedVersions, sourceDirs: [...new Set(entries.flatMap((e) => e.sourceDirs ?? []))] });
379
+ }
329
380
  }
330
381
  }
331
382
  // Defensive dedupe: identical (ecosystem, package, version, scope) rows can
@@ -345,22 +396,18 @@ async function executeStandalonePlanner(params, liveIntel) {
345
396
  : deduped.filter((r) => RISK_LEVELS[r.risk] >= RISK_LEVELS[threshold]);
346
397
  // Sort: critical > high > medium > low, then fixable-now (direct) before
347
398
  // waiting-on-upstream (transitive), then non-breaking first (quick wins).
348
- filtered.sort((a, b) => {
349
- const riskDiff = RISK_LEVELS[b.risk] - RISK_LEVELS[a.risk];
350
- if (riskDiff !== 0)
351
- return riskDiff;
352
- if (a.scope !== b.scope)
353
- return a.scope === "direct" ? -1 : 1;
354
- if (a.breaking !== b.breaking)
355
- return a.breaking ? 1 : -1;
356
- return 0;
357
- });
399
+ filtered.sort(planOrder);
358
400
  const maxRecs = params.max_recommendations ?? 20;
359
401
  const limited = filtered.slice(0, maxRecs);
402
+ // Verify the fix path of every vulnerable step the plan returns: a target
403
+ // no advisory affects, and whether a lockfile refresh already reaches it.
404
+ const unverified = await verifyFixPaths(limited.filter((r) => fixPathInputs.has(r)).map((r) => ({ step: r, ...fixPathInputs.get(r) })), liveIntel);
405
+ limited.sort(planOrder);
360
406
  const upgrades = limited.filter((r) => r.scope === "direct" && r.action === "upgrade_direct");
361
407
  const quickWins = upgrades.filter((r) => !r.breaking).length;
362
408
  const breakingChanges = upgrades.filter((r) => r.breaking).length;
363
409
  const waitingOnUpstream = limited.filter((r) => r.action === "waiting_on_upstream").length;
410
+ const lockfileRefreshes = limited.filter((r) => r.action === "lockfile_refresh").length;
364
411
  const reinstalls = limited.filter((r) => r.action === "reinstall").length;
365
412
  const noFix = limited.filter((r) => r.action === "no_fix_available").length;
366
413
  const inactive = limited.filter((r) => r.action === "not_built_on_this_host").length;
@@ -373,8 +420,12 @@ async function executeStandalonePlanner(params, liveIntel) {
373
420
  parts.push(`${upgrades.length} upgradable directly (${quickWins} non-breaking, ${breakingChanges} crossing a major version)`);
374
421
  if (reinstalls > 0)
375
422
  parts.push(`${reinstalls} need only a reinstall (node_modules behind the lockfile)`);
423
+ if (lockfileRefreshes > 0)
424
+ parts.push(`${lockfileRefreshes} transitive, fixed by a lockfile refresh (every parent already admits a clean version)`);
376
425
  if (waitingOnUpstream > 0)
377
426
  parts.push(`${waitingOnUpstream} transitive, waiting on upstream`);
427
+ if (unverified > 0)
428
+ parts.push(`${unverified} fix target${unverified !== 1 ? "s" : ""} checked only against the installed version's advisories (offline, or OSV or the registry did not answer in time; see fixPathChecked)`);
378
429
  if (noFix > 0)
379
430
  parts.push(`${noFix} vulnerable with no fixed release yet`);
380
431
  if (inactive > 0)
@@ -385,6 +436,9 @@ async function executeStandalonePlanner(params, liveIntel) {
385
436
  if (maintenanceNotices.size > 0) {
386
437
  parts.push(`${maintenanceNotices.size} unmaintained-package notice${maintenanceNotices.size !== 1 ? "s" : ""} not ranked (no fix exists; see vulnerability_scan maintenance_notices)`);
387
438
  }
439
+ const partial = discoveryNote(liveIntel.getDiscovery());
440
+ if (partial)
441
+ parts.push(partial.replace(/[.]$/, ""));
388
442
  const nothing = vulnerabilityDataAvailable ? "nothing to upgrade (no known vulnerability, deprecation, or newer release of a direct dependency)" : "no vulnerability data to plan from";
389
443
  if (only && limited.length === 0)
390
444
  parts.push(emptyAnswerNote(only, liveIntel, includeDev, nothing));
@@ -402,6 +456,7 @@ async function executeStandalonePlanner(params, liveIntel) {
402
456
  quickWins,
403
457
  breakingChanges,
404
458
  waitingOnUpstream,
459
+ lockfileRefreshes,
405
460
  vulnerabilityDataAvailable,
406
461
  provenance: { mode: "standalone_heuristic", note: PROVENANCE_NOTE },
407
462
  };
@@ -22,7 +22,9 @@ export interface PackageRow {
22
22
  severity: SeverityTier;
23
23
  advisories: number;
24
24
  ids: string[];
25
+ /** The version to move to: checked against every advisory of the package when fix_checked says so. */
25
26
  fixed_version: string | null;
27
+ fix_checked: "all_advisories" | "installed_advisories_only";
26
28
  unfixed_advisories?: number;
27
29
  is_direct: boolean | null;
28
30
  is_dev?: true;
@@ -30,7 +32,7 @@ export interface PackageRow {
30
32
  summary: string;
31
33
  }
32
34
  /** One row per package version: worst graded severity, the fix that clears every fixable advisory. */
33
- export declare function groupByPackage(rows: FormattedVuln[]): PackageRow[];
35
+ export declare function groupByPackage(rows: FormattedVuln[], targets?: FullScan["fix_targets"]): PackageRow[];
34
36
  export declare function conciseScanResult(full: FullScan): {
35
37
  scan_time: string;
36
38
  project_root: string;
@@ -38,6 +40,16 @@ export declare function conciseScanResult(full: FullScan): {
38
40
  ecosystems_scanned: string[];
39
41
  total_dependencies_scanned: number;
40
42
  total_vulnerable_packages: number;
43
+ coverage?: {
44
+ complete: false;
45
+ note: string;
46
+ lockfiles_not_scanned: number;
47
+ unresolved_manifests?: {
48
+ manifest: string;
49
+ declared_ranges: number;
50
+ fix: string;
51
+ }[] | undefined;
52
+ } | undefined;
41
53
  by_severity: import("../live/severity-scope.js").SeverityCounts;
42
54
  vulnerable_packages: PackageRow[];
43
55
  vulnerable_packages_note?: string | undefined;
@@ -19,7 +19,7 @@ const MAX_PACKAGES = 40;
19
19
  const MAX_RECOMMENDATIONS = 25;
20
20
  const IDS_PER_ROW = 3;
21
21
  /** One row per package version: worst graded severity, the fix that clears every fixable advisory. */
22
- export function groupByPackage(rows) {
22
+ export function groupByPackage(rows, targets = {}) {
23
23
  const groups = new Map();
24
24
  for (const v of rows) {
25
25
  const key = `${v.ecosystem}\0${v.package}\0${v.current_version}`;
@@ -30,7 +30,8 @@ export function groupByPackage(rows) {
30
30
  const worst = vulns.reduce((a, b) => (SEVERITY_RANK[b.severity] > SEVERITY_RANK[a.severity] ? b : a));
31
31
  const fixes = vulns.map((v) => v.fixed_version).filter((f) => Boolean(f));
32
32
  const unfixed = vulns.length - fixes.length;
33
- const fixed = fixes.length > 0 ? maxVersion(fixes, worst.ecosystem) : null;
33
+ const checked = targets[`${worst.package}@${worst.current_version}`];
34
+ const fixed = checked?.checked === "all_advisories" ? checked.target : fixes.length > 0 ? maxVersion(fixes, worst.ecosystem) : null;
34
35
  // Unknown (null) only when no advisory row knows; any direct row makes it direct.
35
36
  const direct = vulns.some((v) => v.is_direct === true) ? true : vulns.some((v) => v.is_direct === null) ? null : false;
36
37
  out.push({
@@ -40,6 +41,7 @@ export function groupByPackage(rows) {
40
41
  advisories: vulns.length,
41
42
  ids: vulns.slice(0, IDS_PER_ROW).map((v) => v.vuln_id),
42
43
  fixed_version: fixed,
44
+ fix_checked: checked?.checked ?? "installed_advisories_only",
43
45
  ...(unfixed > 0 ? { unfixed_advisories: unfixed } : {}),
44
46
  is_direct: direct,
45
47
  ...(vulns.every((v) => v.is_dev) ? { is_dev: true } : {}),
@@ -57,7 +59,7 @@ export function groupByPackage(rows) {
57
59
  export function conciseScanResult(full) {
58
60
  const names = (rows) => [...new Set(rows.map((v) => `${v.package}@${v.current_version}`))];
59
61
  const vulnerableDrift = full.install_drift.filter((row) => row.vulnerable_installed === true);
60
- const packages = groupByPackage(full.vulnerabilities);
62
+ const packages = groupByPackage(full.vulnerabilities, full.fix_targets);
61
63
  const listed = packages.slice(0, MAX_PACKAGES);
62
64
  const recommendations = full.recommendations.slice(0, MAX_RECOMMENDATIONS);
63
65
  const omittedPackages = packages.length - listed.length;
@@ -69,6 +71,16 @@ export function conciseScanResult(full) {
69
71
  ecosystems_scanned: full.ecosystems_scanned,
70
72
  total_dependencies_scanned: full.total_dependencies_scanned,
71
73
  total_vulnerable_packages: full.total_vulnerable_packages,
74
+ ...(full.coverage
75
+ ? {
76
+ coverage: {
77
+ complete: false,
78
+ note: full.coverage.note,
79
+ lockfiles_not_scanned: full.coverage.lockfiles_not_scanned,
80
+ ...("unresolved_manifests" in full.coverage ? { unresolved_manifests: full.coverage.unresolved_manifests } : {}),
81
+ },
82
+ }
83
+ : {}),
72
84
  // Every listed advisory, before grouping and trimming.
73
85
  by_severity: full.by_severity,
74
86
  vulnerable_packages: listed,
@@ -4,13 +4,50 @@
4
4
  * provenance. Split out of vulnerability-scan.ts, which keeps execution.
5
5
  */
6
6
  import type { InstallDriftRecord, ResolutionProvenance, VulnerabilityScanResult } from "../live/types.js";
7
+ import { type TreeDiscovery } from "../project-tree.js";
8
+ import type { CleanTarget } from "../live/osv-ranges.js";
7
9
  /** What the formatter needs beyond the scan itself. */
8
10
  export interface ScanContext {
9
11
  resolution: ResolutionProvenance | null;
10
12
  reResolvedThisCall: boolean;
11
13
  drift: InstallDriftRecord[];
12
14
  includeDev: boolean;
15
+ /** What the project-tree walk left out; null when the set did not come from a walk. */
16
+ discovery?: TreeDiscovery | null;
17
+ /** Manifests with no lockfile, whose ranged dependencies were not scanned. */
18
+ unresolved?: Array<{
19
+ dir: string;
20
+ manifest: string;
21
+ ranges: number;
22
+ }>;
23
+ /**
24
+ * Upgrade targets checked against every advisory of the package, keyed by
25
+ * fixTargetKey(); absent when the check could not run (offline, budget).
26
+ */
27
+ verifiedTargets?: Map<string, CleanTarget>;
13
28
  }
29
+ /** The key a verified target is stored under: one installed copy of a package. */
30
+ export declare function fixTargetKey(ecosystem: string, name: string, version: string): string;
31
+ /**
32
+ * What the scan did NOT cover, so a clean answer is never mistaken for a
33
+ * complete one: lockfiles past the project-tree bounds, and manifests with no
34
+ * lockfile (declared ranges are not installs, so they are not sent to OSV).
35
+ */
36
+ export declare function scanCoverage(ctx: ScanContext, relative: (dir: string) => string, detailed: boolean): {
37
+ complete: boolean;
38
+ note: string;
39
+ lockfiles_not_scanned: number;
40
+ skipped_lockfiles?: {
41
+ path: string;
42
+ reason: "depth" | "project_limit";
43
+ }[] | undefined;
44
+ walk_truncated?: boolean | undefined;
45
+ unresolved_manifests?: {
46
+ manifest: string;
47
+ declared_ranges: number;
48
+ fix: string;
49
+ }[] | undefined;
50
+ } | null;
14
51
  /**
15
52
  * Collapse advisories that are the same finding under different identifiers.
16
53
  *
@@ -36,6 +73,21 @@ export declare function relativeDir(dir: string, root: string): string;
36
73
  export declare function formatScanResult(result: VulnerabilityScanResult, severityFilter: string | undefined, ctx: ScanContext): {
37
74
  scan_time: string;
38
75
  project: string;
76
+ coverage?: {
77
+ complete: boolean;
78
+ note: string;
79
+ lockfiles_not_scanned: number;
80
+ skipped_lockfiles?: {
81
+ path: string;
82
+ reason: "depth" | "project_limit";
83
+ }[] | undefined;
84
+ walk_truncated?: boolean | undefined;
85
+ unresolved_manifests?: {
86
+ manifest: string;
87
+ declared_ranges: number;
88
+ fix: string;
89
+ }[] | undefined;
90
+ } | undefined;
39
91
  project_root: string;
40
92
  workspaces_with_findings: string[];
41
93
  host_platform: string;
@@ -113,6 +165,11 @@ export declare function formatScanResult(result: VulnerabilityScanResult, severi
113
165
  }[];
114
166
  install_drift: Record<string, unknown>[];
115
167
  recommendations: string[];
168
+ fix_targets: Record<string, {
169
+ target: string | null;
170
+ checked: "all_advisories" | "installed_advisories_only";
171
+ note?: string;
172
+ }>;
116
173
  clean_dependencies: number;
117
174
  scan_duration_ms: number;
118
175
  _meta: {
@@ -127,7 +184,7 @@ export declare function formatScanResult(result: VulnerabilityScanResult, severi
127
184
  resolved_at: string | null;
128
185
  lockfiles: {
129
186
  path: string;
130
- kind: "lockfile" | "manifest";
187
+ kind: "declared_ranges" | "lockfile" | "manifest";
131
188
  mtime: string | null;
132
189
  }[];
133
190
  re_resolved_this_call: boolean;
@@ -9,6 +9,55 @@ import { maxSemver } from "../live/semver-utils.js";
9
9
  import { isMaintenanceNotice } from "../live/maintenance.js";
10
10
  import { countBySeverity, presentedSeverity, scopeAdjustmentReason, SEVERITY_RANK, } from "../live/severity-scope.js";
11
11
  import { buildDriftView, groupDriftRows, lockfileAlsoAffected } from "./install-drift-notes.js";
12
+ import { discoveryNote } from "../project-tree.js";
13
+ import { targetNote } from "../live/fix-paths.js";
14
+ import * as path from "node:path";
15
+ /** The key a verified target is stored under: one installed copy of a package. */
16
+ export function fixTargetKey(ecosystem, name, version) {
17
+ return `${ecosystem}\0${name}\0${version}`;
18
+ }
19
+ /** The command that writes the missing lockfile for a manifest. */
20
+ function lockfileCommand(manifest) {
21
+ const base = path.basename(manifest);
22
+ return base === "Cargo.toml" ? "cargo generate-lockfile" : base === "package.json" ? "npm install --package-lock-only" : "your package manager's lock command";
23
+ }
24
+ /**
25
+ * What the scan did NOT cover, so a clean answer is never mistaken for a
26
+ * complete one: lockfiles past the project-tree bounds, and manifests with no
27
+ * lockfile (declared ranges are not installs, so they are not sent to OSV).
28
+ */
29
+ export function scanCoverage(ctx, relative, detailed) {
30
+ const discovery = ctx.discovery ?? null;
31
+ const unresolved = ctx.unresolved ?? [];
32
+ const notes = [];
33
+ const walk = discoveryNote(discovery);
34
+ if (walk)
35
+ notes.push(walk);
36
+ const ranged = unresolved.reduce((n, u) => n + u.ranges, 0);
37
+ if (ranged > 0) {
38
+ notes.push(`${ranged} dependenc${ranged === 1 ? "y" : "ies"} in ${unresolved.length} manifest${unresolved.length === 1 ? "" : "s"} with no lockfile (${unresolved.slice(0, 3).map((u) => relative(u.manifest)).join(", ")}${unresolved.length > 3 ? ", ..." : ""}) ${ranged === 1 ? "is a declared range" : "are declared ranges"}, not installed versions, and were not checked: run ${[...new Set(unresolved.map((u) => lockfileCommand(u.manifest)))].join(" / ")} there to scan them.`);
39
+ }
40
+ if (notes.length === 0)
41
+ return null;
42
+ return {
43
+ complete: false,
44
+ note: notes.join(" "),
45
+ lockfiles_not_scanned: discovery?.skippedCount ?? 0,
46
+ ...(detailed && discovery && discovery.skipped.length > 0
47
+ ? { skipped_lockfiles: discovery.skipped.map((s) => ({ path: s.path, reason: s.reason })) }
48
+ : {}),
49
+ ...(discovery?.walkTruncated ? { walk_truncated: true } : {}),
50
+ ...(unresolved.length > 0
51
+ ? {
52
+ unresolved_manifests: unresolved.map((u) => ({
53
+ manifest: relative(u.manifest),
54
+ declared_ranges: u.ranges,
55
+ fix: `${lockfileCommand(u.manifest)} in ${relative(u.dir)}`,
56
+ })),
57
+ }
58
+ : {}),
59
+ };
60
+ }
12
61
  const SEVERITY_RULE = "`severity` is the grade every 4DA surface presents: a transitive-only dependency caps critical at high, and a dev-only dependency drops one level (critical to high, high to medium, medium to low). Unknown dev scope gets no discount. `advisory_severity` is the advisory's own tier (counted in `advisory_by_severity`); `by_severity` counts the presented grade.";
13
62
  const RESOLUTION_NOTE = "Dependency versions were read from these files at resolved_at. Every call re-checks them (stat only) and re-resolves when one changes. `cached` and `osv_cached` describe the OSV advisory lookup, not dependency resolution.";
14
63
  /**
@@ -112,9 +161,12 @@ export function formatScanResult(result, severityFilter, ctx) {
112
161
  // "project" label hides the fact that a repo root can span several
113
162
  // independently-versioned workspaces.
114
163
  const scannedProjects = [...new Set(vulns.flatMap((v) => v.sourceDirs ?? []).map(relative))].sort();
164
+ const coverage = scanCoverage(ctx, relative, true);
115
165
  return {
116
166
  scan_time: result.scannedAt,
117
167
  project: result.projectPath.split(/[/\\]/).pop() || result.projectPath,
168
+ // Present only when the scan is partial; read it before trusting a clean result.
169
+ ...(coverage ? { coverage } : {}),
118
170
  project_root: result.projectPath,
119
171
  // Workspaces under `project_root` that contributed a finding. Advisories
120
172
  // below are attributed per entry via `source_dirs`.
@@ -142,7 +194,12 @@ export function formatScanResult(result, severityFilter, ctx) {
142
194
  // npm direct dependencies whose node_modules copy is not what the lockfile
143
195
  // pins. The lockfile is what every other tool reads; this is what runs.
144
196
  install_drift: drift.rows,
145
- recommendations: buildRecommendations(active, result.vulnerabilities, relative),
197
+ recommendations: buildRecommendations(active, result.vulnerabilities, relative, ctx.verifiedTargets),
198
+ // Per vulnerable package version: the upgrade target checked against every
199
+ // advisory OSV has for the package. A row's own `fixed_version` fixes
200
+ // that advisory only, and the highest of them can be affected by another
201
+ // (openssl 0.10.79, GHSA-phqj-4mhp-q6mq).
202
+ fix_targets: fixTargets(active, ctx.verifiedTargets),
146
203
  clean_dependencies: result.cleanCount,
147
204
  scan_duration_ms: result.scanDurationMs,
148
205
  _meta: {
@@ -171,7 +228,31 @@ export function formatScanResult(result, severityFilter, ctx) {
171
228
  },
172
229
  };
173
230
  }
174
- function buildRecommendations(active, all, relative) {
231
+ /** `{ "openssl@0.10.38": { target, checked } }` for every vulnerable package version. */
232
+ function fixTargets(active, verified) {
233
+ const out = {};
234
+ const byCopy = new Map();
235
+ for (const v of active) {
236
+ if (v.installDriftOf)
237
+ continue;
238
+ const key = fixTargetKey(v.ecosystem, v.package, v.currentVersion);
239
+ byCopy.set(key, [...(byCopy.get(key) ?? []), v]);
240
+ }
241
+ for (const [key, vulns] of byCopy) {
242
+ const label = `${vulns[0].package}@${vulns[0].currentVersion}`;
243
+ const clean = verified?.get(key);
244
+ if (clean) {
245
+ const note = targetNote(clean);
246
+ out[label] = { target: clean.target, checked: "all_advisories", ...(note ? { note } : {}) };
247
+ }
248
+ else {
249
+ const fixes = vulns.map((v) => v.fixedVersion).filter((f) => Boolean(f));
250
+ out[label] = { target: fixes.length > 0 ? maxSemver(fixes) : null, checked: "installed_advisories_only" };
251
+ }
252
+ }
253
+ return out;
254
+ }
255
+ function buildRecommendations(active, all, relative, verified) {
175
256
  const lines = [];
176
257
  const rankOf = (vulns) => Math.max(...vulns.map((v) => SEVERITY_RANK[presentedSeverity(v)]));
177
258
  // Name the manifest directories that actually pin the vulnerable version.
@@ -182,32 +263,47 @@ function buildRecommendations(active, all, relative) {
182
263
  return dirs.length > 0 ? ` in ${dirs.join(", ")}` : "";
183
264
  };
184
265
  // Recommendations are derived from platform-active, exploitable vulns only.
266
+ // One per installed VERSION, each advisory counted once: rsa 0.9.10 (in
267
+ // relay and src-tauri) and rsa 0.10.0-rc.18 shared RUSTSEC-2023-0071 and
268
+ // were merged into "rsa 0.9.10 in relay, src-tauri — 2 known
269
+ // vulnerabilities" (2026-10-07 eval): one advisory, two versions.
185
270
  const byPackage = new Map();
186
271
  for (const v of active) {
187
272
  if (v.installDriftOf)
188
273
  continue; // reinstall advice below, not an upgrade
189
- const list = byPackage.get(v.package);
190
- if (list)
191
- list.push(v);
192
- else
193
- byPackage.set(v.package, [v]);
274
+ const key = `${v.ecosystem}\0${v.package}\0${v.currentVersion}`;
275
+ const group = byPackage.get(key) ?? { unique: [], copies: [] };
276
+ // Every copy names a place; the same advisory on another copy is one finding.
277
+ group.copies.push(v);
278
+ if (!group.unique.some((seen) => seen.vulnId === v.vulnId))
279
+ group.unique.push(v);
280
+ byPackage.set(key, group);
194
281
  }
195
- for (const [pkg, pkgVulns] of byPackage) {
282
+ for (const { unique: pkgVulns, copies } of byPackage.values()) {
283
+ const pkg = pkgVulns[0].package;
196
284
  const highest = pkgVulns[0];
197
285
  const breakdown = severityBreakdown(pkgVulns);
198
286
  // Target the HIGHEST published fix so the upgrade resolves every fixable CVE,
199
287
  // not just the first one. (The old code picked the first fix version, which
200
288
  // could leave higher-version-gated CVEs unfixed while claiming to fix them.)
201
- const target = maxSemver(pkgVulns.map((v) => v.fixedVersion).filter((v) => Boolean(v)));
289
+ const highestFix = maxSemver(pkgVulns.map((v) => v.fixedVersion).filter((v) => Boolean(v)));
290
+ // Checked against every advisory of the package when that could run: the
291
+ // highest per-advisory fix can itself be affected by another advisory.
292
+ const clean = verified?.get(fixTargetKey(highest.ecosystem, pkg, highest.currentVersion));
293
+ const target = clean ? clean.target : highestFix;
202
294
  const total = pkgVulns.length;
203
295
  const fixable = pkgVulns.filter((v) => v.fixedVersion).length;
204
- const where = whereOf(pkgVulns);
296
+ const where = whereOf(copies);
205
297
  let text;
206
298
  if (target) {
207
299
  const scope = fixable === total
208
300
  ? `${total} CVE${total > 1 ? "s" : ""}`
209
301
  : `${fixable} of ${total} CVEs (${total - fixable} with no published fix)`;
210
- text = `Upgrade ${pkg} ${highest.currentVersion} → ${target}${where} — resolves ${scope} [${breakdown}]`;
302
+ const note = clean ? targetNote(clean) : null;
303
+ text = `Upgrade ${pkg} ${highest.currentVersion} → ${target}${where} — resolves ${scope} [${breakdown}]${note ? ` (${note})` : ""}`;
304
+ }
305
+ else if (clean && highestFix) {
306
+ text = `Review ${pkg} ${highest.currentVersion}${where} — ${total} known vulnerabilit${total > 1 ? "ies" : "y"} [${breakdown}]; no published version clears every advisory (${clean.floor} is still affected by ${clean.stillAffectedBy.join(", ")})`;
211
307
  }
212
308
  else {
213
309
  text = `Review ${pkg} ${highest.currentVersion}${where} — ${total} known vulnerabilit${total > 1 ? "ies" : "y"} [${breakdown}], no fix version published`;