@4da/mcp-server 6.0.2 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +433 -0
  2. package/README.md +26 -18
  3. package/dist/db.d.ts +73 -8
  4. package/dist/db.js +180 -43
  5. package/dist/doctor.d.ts +1 -1
  6. package/dist/doctor.js +15 -9
  7. package/dist/embeddings.js +19 -7
  8. package/dist/gitignore.js +9 -3
  9. package/dist/index.js +43 -4
  10. package/dist/live/cache.d.ts +31 -2
  11. package/dist/live/cache.js +113 -1
  12. package/dist/live/changelog-classify.d.ts +9 -3
  13. package/dist/live/changelog-classify.js +125 -12
  14. package/dist/live/changelog.d.ts +22 -3
  15. package/dist/live/changelog.js +132 -38
  16. package/dist/live/dev-scope.d.ts +2 -2
  17. package/dist/live/fix-paths.d.ts +118 -0
  18. package/dist/live/fix-paths.js +498 -0
  19. package/dist/live/github-releases.d.ts +69 -0
  20. package/dist/live/github-releases.js +252 -0
  21. package/dist/live/go-registry.d.ts +5 -0
  22. package/dist/live/go-registry.js +1 -1
  23. package/dist/live/go-releases.d.ts +48 -0
  24. package/dist/live/go-releases.js +196 -0
  25. package/dist/live/index.d.ts +34 -8
  26. package/dist/live/index.js +66 -7
  27. package/dist/live/lockfile-parents.d.ts +61 -0
  28. package/dist/live/lockfile-parents.js +360 -0
  29. package/dist/live/lockfile-parsers-pygo.d.ts +31 -0
  30. package/dist/live/lockfile-parsers-pygo.js +178 -28
  31. package/dist/live/lockfile-parsers.d.ts +4 -1
  32. package/dist/live/lockfile-parsers.js +57 -24
  33. package/dist/live/lockfile-types.d.ts +12 -3
  34. package/dist/live/lockfile-types.js +2 -1
  35. package/dist/live/osv-ranges.d.ts +85 -0
  36. package/dist/live/osv-ranges.js +136 -0
  37. package/dist/live/osv-scanner.d.ts +1 -0
  38. package/dist/live/osv-scanner.js +85 -78
  39. package/dist/live/package-archive.d.ts +2 -0
  40. package/dist/live/package-archive.js +7 -4
  41. package/dist/live/pypi-releases.d.ts +44 -0
  42. package/dist/live/pypi-releases.js +103 -0
  43. package/dist/live/range-satisfies.d.ts +21 -0
  44. package/dist/live/range-satisfies.js +190 -0
  45. package/dist/live/rate-limiter.d.ts +6 -0
  46. package/dist/live/rate-limiter.js +5 -0
  47. package/dist/live/registry-health.js +2 -0
  48. package/dist/live/release-metadata.d.ts +17 -8
  49. package/dist/live/release-metadata.js +6 -3
  50. package/dist/live/resolution.d.ts +11 -1
  51. package/dist/live/resolution.js +16 -6
  52. package/dist/live/semver-utils.d.ts +8 -0
  53. package/dist/live/semver-utils.js +10 -2
  54. package/dist/live/types.d.ts +17 -1
  55. package/dist/live/upgrade-go.d.ts +62 -0
  56. package/dist/live/upgrade-go.js +130 -0
  57. package/dist/live/upgrade-pypi.d.ts +42 -0
  58. package/dist/live/upgrade-pypi.js +85 -0
  59. package/dist/live/upgrade-sources.d.ts +30 -11
  60. package/dist/live/upgrade-sources.js +93 -21
  61. package/dist/live/zip-archive.d.ts +22 -0
  62. package/dist/live/zip-archive.js +98 -0
  63. package/dist/memory-store.d.ts +19 -0
  64. package/dist/memory-store.js +8 -0
  65. package/dist/native-bindings.d.ts +7 -2
  66. package/dist/native-bindings.js +4 -0
  67. package/dist/project-tree.d.ts +50 -8
  68. package/dist/project-tree.js +121 -29
  69. package/dist/schema-registry.js +5 -5
  70. package/dist/server-instructions.d.ts +6 -0
  71. package/dist/server-instructions.js +20 -15
  72. package/dist/sqlite-driver.d.ts +134 -0
  73. package/dist/sqlite-driver.js +359 -0
  74. package/dist/tool-dispatch.d.ts +8 -1
  75. package/dist/tool-dispatch.js +17 -2
  76. package/dist/tools/agent-memory.js +10 -2
  77. package/dist/tools/app-plan.d.ts +11 -1
  78. package/dist/tools/app-plan.js +32 -6
  79. package/dist/tools/briefing-task-scope.d.ts +21 -2
  80. package/dist/tools/briefing-task-scope.js +97 -4
  81. package/dist/tools/decision-memory.js +9 -3
  82. package/dist/tools/dependency-check-signals.d.ts +23 -5
  83. package/dist/tools/dependency-check-signals.js +62 -19
  84. package/dist/tools/dependency-check-versions.d.ts +94 -0
  85. package/dist/tools/dependency-check-versions.js +213 -0
  86. package/dist/tools/dependency-check.d.ts +27 -11
  87. package/dist/tools/dependency-check.js +321 -74
  88. package/dist/tools/dependency-health.d.ts +1 -1
  89. package/dist/tools/dependency-health.js +5 -0
  90. package/dist/tools/knowledge-gap-ranges.d.ts +1 -1
  91. package/dist/tools/knowledge-gap-ranges.js +1 -1
  92. package/dist/tools/knowledge-gap-scope.d.ts +2 -2
  93. package/dist/tools/project-scope.d.ts +45 -0
  94. package/dist/tools/project-scope.js +119 -0
  95. package/dist/tools/record-feedback.js +1 -1
  96. package/dist/tools/upgrade-impact-callsites.d.ts +1 -0
  97. package/dist/tools/upgrade-impact-callsites.js +23 -5
  98. package/dist/tools/upgrade-impact-changelog.d.ts +41 -0
  99. package/dist/tools/upgrade-impact-changelog.js +79 -0
  100. package/dist/tools/upgrade-impact-imports.d.ts +42 -0
  101. package/dist/tools/upgrade-impact-imports.js +260 -0
  102. package/dist/tools/upgrade-impact-literals.js +1 -1
  103. package/dist/tools/upgrade-impact-report.d.ts +23 -0
  104. package/dist/tools/upgrade-impact-report.js +45 -6
  105. package/dist/tools/upgrade-impact.d.ts +13 -4
  106. package/dist/tools/upgrade-impact.js +236 -85
  107. package/dist/tools/upgrade-planner-fixpaths.d.ts +40 -0
  108. package/dist/tools/upgrade-planner-fixpaths.js +0 -0
  109. package/dist/tools/upgrade-planner.d.ts +31 -3
  110. package/dist/tools/upgrade-planner.js +74 -19
  111. package/dist/tools/vulnerability-scan-concise.d.ts +13 -1
  112. package/dist/tools/vulnerability-scan-concise.js +15 -3
  113. package/dist/tools/vulnerability-scan-format.d.ts +58 -1
  114. package/dist/tools/vulnerability-scan-format.js +107 -11
  115. package/dist/tools/vulnerability-scan.d.ts +104 -13
  116. package/dist/tools/vulnerability-scan.js +27 -3
  117. package/dist/tools/what-should-i-know.d.ts +22 -2
  118. package/dist/tools/what-should-i-know.js +122 -12
  119. package/package.json +10 -32
@@ -3,53 +3,69 @@
3
3
  * dependency_check tool
4
4
  *
5
5
  * A pre-flight verdict for ONE proposed change per item: adding a dependency
6
- * at a version, or bumping an installed one. An agent calls this before it
7
- * edits a manifest and gets `proceed | wait | review | avoid | unknown`, a
8
- * one-line reason, and the signals behind it, each with evidence.
6
+ * at a version, pinning one, or moving an installed one to another version.
7
+ * An agent calls this before it writes a version into a manifest and gets
8
+ * `proceed | wait | review | avoid | unknown`, a one-line reason, and the
9
+ * signals behind it, each with evidence. npm, crates.io, PyPI and Go.
10
+ *
11
+ * `to` is an exact version, "latest", or a constraint ("7.x", "^1.20",
12
+ * "~=3.1", "v1"). For a constraint the item also returns `range_check`: every
13
+ * release it admits judged against every advisory OSV.dev lists for the
14
+ * package, with the smallest and newest safe one, and the verdict is given
15
+ * for the version it recommends (the smallest safe upgrade above `from`, or
16
+ * for a new dependency the newest safe release). An exact `to` that an
17
+ * advisory affects gets the same `range_check` over its own release line, so
18
+ * the answer to "is 7.5.10 safe?" names 7.5.11.
9
19
  *
10
20
  * Signals (see dependency-check-signals.ts): known advisories on the target
11
21
  * (OSV), release age (hold releases < 3 days unless they fix an advisory on
12
- * the installed version), publish-trust drop, newly added install scripts
13
- * (npm), newly introduced runtime dependencies that are themselves < 30 days
14
- * old, yanked/deprecated/unpublished targets, and the upgrade type.
22
+ * the installed version), publish-trust drop and newly added install scripts
23
+ * (npm; trust also crates.io), newly introduced runtime dependencies that are
24
+ * themselves < 30 days old (npm, crates.io), yanked / deprecated / unpublished
25
+ * / retracted targets, and the upgrade type. A signal a registry cannot
26
+ * supply says "not applicable", never a made-up value.
15
27
  *
16
28
  * Honesty contract: a registry or OSV that cannot be reached makes the item
17
29
  * `unknown`, never `proceed`. A package missing from the public registry is
18
30
  * `unknown` with that said (private package, or a misspelled/hallucinated
19
31
  * name), never treated as clean.
20
32
  *
21
- * Privacy: registry requests carry the package name only — the installed and
22
- * target versions are picked out of the full release list locally. Only OSV
23
- * receives name + version, exactly as vulnerability_scan already does.
33
+ * Privacy: npm, crates.io and PyPI requests carry the package name only — the
34
+ * installed and target versions are picked out of the full release list
35
+ * locally. The Go proxy lists versions without dates, so the judged versions'
36
+ * `.info` is read from it. OSV receives name + version, as vulnerability_scan
37
+ * already does, and the package name for its advisory list.
24
38
  */
25
39
  import { isMaintenanceNotice } from "../live/maintenance.js";
26
- import { compareVersionPrecedence, parseSemverPrecedence } from "../live/semver-precedence.js";
27
- import { advisoriesSignal, decide, installScriptSignal, introducedDependencies, newDependenciesSignal, publishTrustSignal, releaseAgeSignal, selectBaseline, upgradeTypeSignal, withdrawnSignal, } from "./dependency-check-signals.js";
40
+ import { goPathMajor, goVersionMajor } from "../live/upgrade-go.js";
41
+ import { advisoriesSignal, decide, installScriptSignal, introducedDependencies, newDependenciesSignal, publishTrustSignal, releaseAgeSignal, selectBaseline, upgradeTypeSignal, withdrawnSignal, WAIT_DAYS, } from "./dependency-check-signals.js";
42
+ import { checkRange, compareIn, isPrereleaseIn, lineOf, parseTarget, rangeAdmits } from "./dependency-check-versions.js";
28
43
  const MAX_ITEMS = 25;
29
44
  /** First-publish lookups for newly introduced dependencies, per call (each is a registry read). */
30
45
  const MAX_NEW_DEP_LOOKUPS = 40;
31
46
  const NPM_CONCURRENCY = 4;
32
- const PRIVACY_NOTE = "Registry requests carried package names only (npm full packument, crates.io versions API and sparse index); " +
33
- "installed and target versions were never sent to a registry. OSV.dev received package names and versions, as vulnerability_scan does.";
47
+ const ECOSYSTEMS = ["npm", "crates.io", "PyPI", "Go"];
48
+ const DAY_MS = 86_400_000;
49
+ const PRIVACY_NOTE = "Registry requests carried package names only (npm full packument, crates.io versions API and sparse index, PyPI JSON API, Go proxy version list); " +
50
+ "installed and target versions were never sent to a registry, except the Go proxy's per-version .info for the versions judged (the list has no dates). " +
51
+ "OSV.dev received package names and versions, as vulnerability_scan does.";
34
52
  const VERDICT_RULE = "avoid > review > unknown > wait > proceed. unknown = a registry or OSV.dev lookup failed (never read as proceed); " +
35
53
  "a concrete avoid/review finding still wins over a missing source.";
36
54
  export const dependencyCheckTool = {
37
55
  name: "dependency_check",
38
56
  description: [
39
- "Pre-flight verdict for adding a dependency or changing one to a specific version. Call BEFORE you add a package to a manifest or apply a version bump (including bumps proposed by upgrade_planner or a bot), and act on the verdict.",
57
+ "Is this dependency version safe to write? Call BEFORE you write any version into a manifest or lockfile: adding, pinning, upgrading, bumping or downgrading a package, or choosing a version (npm, crates.io, PyPI, Go). Pass the exact version you intend to write, or the constraint you were given (\"7.x\", \"^1.20\", \"~=3.1\", \"v1\") to get the smallest and newest safe release in it. Write only a version that came back `proceed`. Your memory of which release is safe is out of date; this reads the registry and OSV.dev now.",
40
58
  "",
41
- "Per item it returns `verdict` (proceed | wait | review | avoid | unknown), a one-line `reason`, and `signals`, each with `id`, `value`, `evidence` and the verdict that signal implies alone (`effect`):",
42
- "- advisories: known OSV advisories affecting `to`, and those this change fixes on `from`. High/critical on `to` -> avoid; other -> review.",
43
- "- release_age: days since `to` was published. Under 3 days -> wait, unless `to` fixes an advisory affecting `from` (a security fix is not held).",
44
- "- publish_trust: trust level of `to` vs `from` (or, for a new dependency, the previous release on the same line). 2 = trusted publisher (npm `_npmUser.trustedPublisher`, crates.io `trustpub_data`), 1 = npm provenance attestation, 0 = neither. A drop -> review.",
45
- "- install_script_added (npm): `to` runs preinstall/install/postinstall and `from` did not -> review. For a new dependency, information only.",
46
- "- new_dependencies: runtime dependencies added since the baseline, each with its first-publish age. Any under 30 days old, or no longer on the registry -> review.",
47
- "- yanked_or_deprecated: `to` yanked, unpublished or deprecated -> avoid.",
48
- "- upgrade_type: patch | minor | major (a 0.x minor counts as major) | downgrade | new_dependency. Information only.",
59
+ "Per item: `verdict` (proceed | wait | review | avoid | unknown), a one-line `reason`, `signals` (each with `id`, `value`, `evidence`, `effect`), and `range_check` when `to` is a constraint or an exact `to` has an advisory: `smallest_safe_in_range`, `newest_safe_in_range`, `safe_versions`, `smallest_safe_upgrade` (above `from`). For a constraint the verdict is for the version it picks: the smallest safe upgrade above `from`, else the newest safe release.",
60
+ "- advisories: OSV advisories affecting `to` (high/critical -> avoid, other -> review) and those this change fixes on `from`.",
61
+ "- release_age: under 3 days -> wait, unless `to` fixes an advisory affecting `from`, or every release on its line past the hold has an advisory and no safe one is past it (then proceed; tell the user its age).",
62
+ "- yanked_or_deprecated: yanked, unpublished, retracted (Go) or deprecated -> avoid.",
63
+ "- publish_trust (npm, crates.io): trusted publisher / provenance dropped -> review. install_script_added (npm): new install script -> review. new_dependencies (npm, crates.io): a new runtime dependency under 30 days old -> review. Not applicable elsewhere, and said so.",
64
+ "- upgrade_type: major (0.x minor counts) -> review: call upgrade_impact for what it breaks.",
49
65
  "",
50
- "Verdict precedence: avoid > review > unknown > wait > proceed. `unknown` means a registry or OSV.dev lookup failed, or the package is not on the public registry (private package, or a misspelled/hallucinated name); it is never a silent proceed. Apply only `proceed` items; report the others with their evidence. For WHAT the bump changes in your code, call upgrade_impact.",
66
+ "`unknown` = a lookup failed or the package is not on the public registry (private, misspelled or hallucinated name); never a silent proceed.",
51
67
  "",
52
- "Privacy: registry requests carry the package NAME only (npm full packument, crates.io versions API and sparse index); the installed and target versions are never sent to a registry. OSV.dev receives package names and versions, as vulnerability_scan does. Set FOURDA_OFFLINE=true to disable all lookups (every item then returns unknown).",
68
+ "Privacy: registries get the package name only (Go: also the judged versions' .info, as `go get` does); OSV.dev gets names and versions, as vulnerability_scan does. FOURDA_OFFLINE=true disables lookups (every item unknown).",
53
69
  ].join("\n"),
54
70
  inputSchema: {
55
71
  type: "object",
@@ -62,12 +78,15 @@ export const dependencyCheckTool = {
62
78
  items: {
63
79
  type: "object",
64
80
  properties: {
65
- ecosystem: { type: "string", enum: ["npm", "crates.io"], description: "Package registry." },
81
+ ecosystem: { type: "string", enum: ["npm", "crates.io", "PyPI", "Go"], description: "Package registry." },
66
82
  package: {
67
83
  type: "string",
68
- description: 'Package name exactly as published (e.g. "axios", "@scope/pkg", "serde_json").',
84
+ description: 'Package name exactly as published ("axios", "@scope/pkg", "serde_json", "Jinja2", "golang.org/x/net", "github.com/labstack/echo/v4").',
85
+ },
86
+ to: {
87
+ type: "string",
88
+ description: 'The version you intend to write ("7.5.10", "3.1.4", "v0.23.0"), "latest", or the constraint it must satisfy ("7.x", "^1.20", "=0.17", "~=3.1", ">=2,<3", "v1") to get the safe releases in it.',
69
89
  },
70
- to: { type: "string", description: 'Exact version you intend to install (e.g. "1.14.1"), or "latest" for the newest stable release; not a range.' },
71
90
  from: { type: "string", description: "Exact version currently installed. Omit when adding a new dependency." },
72
91
  },
73
92
  required: ["ecosystem", "package", "to"],
@@ -83,15 +102,28 @@ export async function executeDependencyCheck(_db, params, liveIntel, opts = {})
83
102
  const checkedAt = now.toISOString();
84
103
  if (!liveIntel || !liveIntel.isEnabled()) {
85
104
  const reason = "Live lookups are disabled (FOURDA_OFFLINE=true); nothing about these versions could be checked.";
86
- return finish(checkedAt, requested.map((item) => ({ ...head(item), verdict: "unknown", reason, signals: [] })), { offline: true, osvCached: false });
105
+ return finish(checkedAt, requested.map((r) => ({ ...head(provisional(r)), verdict: "unknown", reason, signals: [] })), { offline: true, osvCached: false });
87
106
  }
88
107
  const sources = liveIntel.getReleaseSources();
89
- const indexes = await fetchIndexes(requested, sources);
90
- // `to: "latest"` becomes the newest stable release the registry lists. Agents
91
- // adding a package pass it naturally; rejecting it cost a retry in 7 of 24
92
- // dependency_check trials of the 2026-10-03 agent eval.
93
- const items = requested.map((item) => resolveLatest(item, indexes.get(lookupKey(item))));
94
- const osvDeps = items.flatMap((item) => [item.to, item.from].filter((v) => Boolean(v)).map((version) => osvDep(item, version)));
108
+ const fixSources = liveIntel.getFixPathSources();
109
+ const indexes = await fetchIndexes(requested.map(provisional), sources, new Set(requested.filter(needsFullList).map(lookupKey)));
110
+ // Constraints are judged against the package's whole advisory list.
111
+ const advisoryLists = await packageAdvisoryLists(fixSources, requested.filter((r) => r.target.kind === "range" || r.target.kind === "exact_or_series"));
112
+ const items = requested.map((r) => resolveTarget(r, indexes.get(lookupKey(r)), advisoryLists.get(lookupKey(r)), now));
113
+ // Go dates come from per-version .info: read them for versions chosen from the list
114
+ // (a constraint, "latest", or `v2.0.0` listed as `v2.0.0+incompatible`).
115
+ const goRedo = items.filter((i) => {
116
+ const lookup = indexes.get(lookupKey(i));
117
+ return i.ecosystem === "Go" && !i.unresolved && lookup?.status === "ok" && lookup.data.releases[i.to] && !lookup.data.releases[i.to].publishedAt;
118
+ });
119
+ if (goRedo.length > 0) {
120
+ for (const [key, lookup] of await fetchIndexes(goRedo, sources, new Set()))
121
+ indexes.set(key, lookup);
122
+ await settleGoPicks(goRedo, indexes, sources.Go, now);
123
+ }
124
+ const osvDeps = items
125
+ .filter((i) => !i.unresolved)
126
+ .flatMap((item) => [item.to, item.from].filter((v) => Boolean(v)).map((version) => osvDep(item, version)));
95
127
  const scan = await liveIntel.queryAdvisories(osvDeps);
96
128
  const byInstance = new Map();
97
129
  for (const v of scan.vulnerabilities) {
@@ -105,6 +137,20 @@ export async function executeDependencyCheck(_db, params, liveIntel, opts = {})
105
137
  to: refs(byInstance.get(instanceKey(item.ecosystem, item.package, item.to))),
106
138
  from: item.from ? refs(byInstance.get(instanceKey(item.ecosystem, item.package, item.from))) : [],
107
139
  };
140
+ // An exact `to` an advisory affects: the safe releases on its own line.
141
+ const affectedExact = items.filter((i) => !i.unresolved && !i.rangeCheck && advisoriesFor(i).available && hasRealAdvisory(advisoriesFor(i)));
142
+ const moreLists = await packageAdvisoryLists(fixSources, affectedExact.filter((i) => !advisoryLists.has(lookupKey(i))));
143
+ for (const [k, v] of moreLists)
144
+ advisoryLists.set(k, v);
145
+ for (const item of affectedExact) {
146
+ const lookup = indexes.get(lookupKey(item));
147
+ const advisories = advisoryLists.get(lookupKey(item));
148
+ const line = lineOf(item.ecosystem, item.to);
149
+ if (lookup.status !== "ok" || !advisories || !line)
150
+ continue;
151
+ item.rangeCheck = checkRange({ ecosystem: item.ecosystem, index: lookup.data, range: line, basis: "same_line", advisories, from: item.from });
152
+ }
153
+ const holdWaivers = await findHoldWaivers(items, indexes, advisoryLists, fixSources, sources.Go, advisoriesFor, now);
108
154
  // Phase 2: first-publish dates of newly introduced dependencies.
109
155
  const plans = items.map((item) => planItem(item, indexes.get(lookupKey(item))));
110
156
  const wantedCreated = new Map();
@@ -121,16 +167,20 @@ export async function executeDependencyCheck(_db, params, liveIntel, opts = {})
121
167
  created.set(key, lookup.status === "ok" ? { status: "ok", createdAt: lookup.data } : lookup);
122
168
  });
123
169
  const results = plans.map((p) => {
170
+ const extra = p.item.rangeCheck ? { range_check: p.item.rangeCheck } : {};
124
171
  if (p.blocked) {
125
172
  // No release metadata: only the advisory lookup can still speak, and
126
173
  // only a negative finding is worth more than "unknown".
174
+ if (p.item.unresolved)
175
+ return { ...head(p.item), verdict: "unknown", reason: p.blocked, signals: [], ...extra };
127
176
  const advisories = advisoriesSignal(p.item, advisoriesFor(p.item));
128
177
  const negative = advisories.effect === "avoid" || advisories.effect === "review";
129
178
  return {
130
179
  ...head(p.item),
131
180
  verdict: negative ? advisories.effect : "unknown",
132
- reason: negative ? `advisories: ${advisories.evidence} | ${p.blocked}` : p.blocked,
181
+ reason: withAlternatives(negative ? `advisories: ${advisories.evidence} | ${p.blocked}` : p.blocked, p.item, negative),
133
182
  signals: [advisories],
183
+ ...extra,
134
184
  };
135
185
  }
136
186
  const { item, index } = p;
@@ -145,17 +195,205 @@ export async function executeDependencyCheck(_db, params, liveIntel, opts = {})
145
195
  }
146
196
  const signals = [
147
197
  advisories,
148
- releaseAgeSignal(item, to, advisories, now),
198
+ releaseAgeSignal(item, to, advisories, now, holdWaivers.get(item)),
149
199
  publishTrustSignal(item, to, base),
150
200
  installScriptSignal(item, to, item.from ? index.releases[item.from] ?? null : null),
151
- newDependenciesSignal(to, base, p.introduced, createdForItem),
201
+ newDependenciesSignal(item, to, base, p.introduced, createdForItem),
152
202
  withdrawnSignal(item, to),
153
203
  upgradeTypeSignal(item),
154
204
  ];
155
- return { ...head(item), ...decide(signals), signals };
205
+ const decided = decide(signals);
206
+ const unsafe = decided.verdict === "avoid" || decided.verdict === "review";
207
+ return { ...head(item), verdict: decided.verdict, reason: withAlternatives(decided.reason, item, unsafe), signals, ...extra };
156
208
  });
157
209
  return finish(checkedAt, results, { offline: scan.offline, osvCached: scan.cached });
158
210
  }
211
+ /** The reason, plus the safe releases to write instead when the verdict is against `to`. */
212
+ function withAlternatives(reason, item, unsafe) {
213
+ const rc = item.rangeCheck;
214
+ if (!rc)
215
+ return reason;
216
+ const where = rc.basis === "requested" ? `in ${rc.range}` : `on ${item.to}'s line (${rc.range})`;
217
+ if (rc.smallest_safe_in_range === null) {
218
+ const ids = rc.affecting_all ?? [];
219
+ const all = ids.length ? `: ${ids.slice(0, 3).join(", ")}${ids.length > 3 ? ` and ${ids.length - 3} more` : ""} affect every one` : "";
220
+ const outside = rc.newest_safe_outside_range ? ` Newest safe release outside it: ${rc.newest_safe_outside_range}.` : "";
221
+ return `${reason} | No release ${where} is free of known advisories${all}.${outside}`;
222
+ }
223
+ const upgrade = rc.smallest_safe_upgrade && rc.smallest_safe_upgrade !== rc.smallest_safe_in_range ? `, smallest above ${item.from} ${rc.smallest_safe_upgrade}` : "";
224
+ const safe = `Safe ${where}: smallest ${rc.smallest_safe_in_range}${upgrade}, newest ${rc.newest_safe_in_range}.`;
225
+ if (rc.basis === "same_line" && !unsafe)
226
+ return reason;
227
+ return `${reason} | ${safe}`;
228
+ }
229
+ function hasRealAdvisory(facts) {
230
+ return facts.available && facts.to.some((a) => !a.maintenance);
231
+ }
232
+ /** Before resolution: an exact `to` as is, anything else shown as the caller wrote it. */
233
+ function provisional(r) {
234
+ const to = r.target.kind === "exact" ? r.target.version : r.raw;
235
+ return { ecosystem: r.ecosystem, package: r.package, to, ...(r.from ? { from: r.from } : {}) };
236
+ }
237
+ function needsFullList(r) {
238
+ return r.target.kind !== "exact";
239
+ }
240
+ /**
241
+ * One version per item: an exact `to` (Go: `+incompatible` when that is how
242
+ * the proxy lists it), "latest" (newest stable, not withdrawn), or the
243
+ * version a constraint recommends.
244
+ */
245
+ function resolveTarget(r, lookup, advisories, now) {
246
+ const base = provisional(r);
247
+ if (lookup.status !== "ok")
248
+ return base;
249
+ const index = lookup.data;
250
+ let target = r.target;
251
+ if (target.kind === "exact_or_series")
252
+ target = index.releases[target.version] ? { kind: "exact", version: target.version } : { kind: "range", range: target.range };
253
+ if (target.kind === "exact") {
254
+ let version = target.version;
255
+ if (r.ecosystem === "Go" && !index.releases[version] && index.releases[`${version}+incompatible`])
256
+ version = `${version}+incompatible`;
257
+ // PEP 440 spellings differ ("3.1.06", "1.0-post1"); use the registry's.
258
+ if (r.ecosystem === "PyPI" && !index.releases[version]) {
259
+ version = Object.keys(index.releases).find((v) => compareIn("PyPI", v, version) === 0) ?? version;
260
+ }
261
+ return { ...base, to: version };
262
+ }
263
+ if (target.kind === "latest") {
264
+ const newest = newestOf(r.ecosystem, Object.values(index.releases).filter((x) => !x.withdrawn).map((x) => x.version));
265
+ return newest ? { ...base, to: newest, requested: "latest" } : base;
266
+ }
267
+ const range = target.range;
268
+ const admitted = Object.values(index.releases)
269
+ .filter((x) => !x.withdrawn && rangeAdmits(r.ecosystem, range, x.version) === true)
270
+ .map((x) => x.version);
271
+ if (admitted.length === 0) {
272
+ return { ...base, requested: r.raw, unresolved: `No published, non-withdrawn release of ${r.package} matches ${range}.` };
273
+ }
274
+ if (!advisories) {
275
+ // Without the advisory list no release can be called safe; judge the newest one on its own advisories.
276
+ return { ...base, to: newestOf(r.ecosystem, admitted), requested: r.raw };
277
+ }
278
+ const rangeCheck = checkRange({ ecosystem: r.ecosystem, index, range, basis: "requested", advisories, from: r.from });
279
+ const safe = rangeCheck.safe_versions;
280
+ const settled = (v) => {
281
+ const at = index.releases[v]?.publishedAt;
282
+ return !at || (now.getTime() - Date.parse(at)) / DAY_MS >= WAIT_DAYS;
283
+ };
284
+ const pick = r.from
285
+ ? rangeCheck.smallest_safe_upgrade
286
+ : ([...safe].reverse().find(settled) ?? rangeCheck.newest_safe_in_range);
287
+ return { ...base, to: pick ?? newestOf(r.ecosystem, admitted), requested: r.raw, rangeCheck };
288
+ }
289
+ /** `.info` reads spent looking for a Go release past the hold, per item. */
290
+ const GO_SETTLE_READS = 3;
291
+ /**
292
+ * Go lists carry no dates, so a constraint's newest safe release was picked
293
+ * blind to the 3-day hold. When it turns out to be inside it, step down the
294
+ * safe list (newest first) to the newest one that is not, reading `.info`
295
+ * for each step. A pick above `from` (the smallest fix) is left alone.
296
+ */
297
+ async function settleGoPicks(items, indexes, go, now) {
298
+ const young = (at) => Boolean(at) && (now.getTime() - Date.parse(at)) / DAY_MS < WAIT_DAYS;
299
+ for (const item of items) {
300
+ const rc = item.rangeCheck;
301
+ const lookup = indexes.get(lookupKey(item));
302
+ if (!rc || item.from || rc.basis !== "requested" || lookup?.status !== "ok")
303
+ continue;
304
+ if (!young(lookup.data.releases[item.to]?.publishedAt))
305
+ continue;
306
+ const older = rc.safe_versions.filter((v) => compareIn("Go", v, item.to) < 0).reverse().slice(0, GO_SETTLE_READS);
307
+ for (const v of older) {
308
+ const next = await go.getReleases(item.package, [v, item.to]);
309
+ if (next.status !== "ok")
310
+ break;
311
+ if (next.data.releases[v])
312
+ lookup.data.releases[v] = next.data.releases[v];
313
+ if (!young(next.data.releases[v]?.publishedAt)) {
314
+ item.to = v;
315
+ break;
316
+ }
317
+ }
318
+ }
319
+ }
320
+ /** Newest release, stable ones first. */
321
+ function newestOf(ecosystem, versions) {
322
+ const stable = versions.filter((v) => !isPrereleaseIn(ecosystem, v));
323
+ const pool = stable.length > 0 ? stable : versions;
324
+ return pool.reduce((max, v) => (max === null || compareIn(ecosystem, v, max) > 0 ? v : max), null);
325
+ }
326
+ /** Go publish dates read per item to decide a hold waiver (the list has none). */
327
+ const GO_WAIVER_READS = 8;
328
+ function isYoung(at, now) {
329
+ return Boolean(at) && (now.getTime() - Date.parse(at)) / DAY_MS < WAIT_DAYS;
330
+ }
331
+ /**
332
+ * Clean `to` versions inside the release-age hold for which holding back
333
+ * leaves nothing safe to write: every safe release on the line is inside the
334
+ * hold as well, and the line has older releases, all with a known advisory.
335
+ * A package whose only releases are new keeps the hold, which is what it is for.
336
+ * An undated release (Go lists carry no dates) counts as old, never as safe.
337
+ */
338
+ async function findHoldWaivers(items, indexes, advisoryLists, fixSources, go, advisoriesFor, now) {
339
+ const out = new Map();
340
+ const held = items.filter((i) => {
341
+ const lookup = indexes.get(lookupKey(i));
342
+ if (i.unresolved || lookup?.status !== "ok")
343
+ return false;
344
+ const facts = advisoriesFor(i);
345
+ return facts.available && !hasRealAdvisory(facts) && isYoung(lookup.data.releases[i.to]?.publishedAt, now);
346
+ });
347
+ if (held.length === 0)
348
+ return out;
349
+ const missing = held.filter((i) => !advisoryLists.has(lookupKey(i)));
350
+ for (const [k, v] of await packageAdvisoryLists(fixSources, missing))
351
+ advisoryLists.set(k, v);
352
+ for (const item of held) {
353
+ const lookup = indexes.get(lookupKey(item));
354
+ const advisories = advisoryLists.get(lookupKey(item));
355
+ const line = lineOf(item.ecosystem, item.to);
356
+ if (lookup?.status !== "ok" || !advisories || !line)
357
+ continue;
358
+ const index = lookup.data;
359
+ const rc = checkRange({ ecosystem: item.ecosystem, index, range: line, basis: "same_line", advisories, from: item.from });
360
+ // safe_versions holds the newest few; it must reach the smallest to cover them all.
361
+ if (rc.smallest_safe_in_range === null || !rc.safe_versions.includes(rc.smallest_safe_in_range))
362
+ continue;
363
+ if (item.ecosystem === "Go") {
364
+ const undated = rc.safe_versions.filter((v) => !index.releases[v]?.publishedAt).slice(0, GO_WAIVER_READS);
365
+ if (undated.length > 0) {
366
+ const read = await go.getReleases(item.package, undated);
367
+ if (read.status === "ok") {
368
+ for (const v of undated)
369
+ if (read.data.releases[v])
370
+ index.releases[v] = read.data.releases[v];
371
+ }
372
+ }
373
+ }
374
+ if (!rc.safe_versions.every((v) => isYoung(index.releases[v]?.publishedAt, now)))
375
+ continue;
376
+ const safe = new Set(rc.safe_versions);
377
+ const older = Object.values(index.releases).filter((r) => !r.withdrawn &&
378
+ !safe.has(r.version) &&
379
+ !isPrereleaseIn(item.ecosystem, r.version) &&
380
+ rangeAdmits(item.ecosystem, line, r.version) === true &&
381
+ !isYoung(r.publishedAt, now));
382
+ if (older.length > 0)
383
+ out.set(item, { line, olderVulnerable: older.length });
384
+ }
385
+ return out;
386
+ }
387
+ async function packageAdvisoryLists(fixSources, items) {
388
+ const out = new Map();
389
+ if (!fixSources)
390
+ return out;
391
+ const unique = new Map(items.map((i) => [lookupKey(i), i]));
392
+ await mapLimit([...unique.entries()], NPM_CONCURRENCY, async ([key, i]) => {
393
+ out.set(key, await fixSources.packageAdvisories(i.ecosystem, i.package).catch(() => null));
394
+ });
395
+ return out;
396
+ }
159
397
  function planItem(item, lookup) {
160
398
  if (lookup.status === "unreachable") {
161
399
  return { item, blocked: `Registry could not be reached (${lookup.detail}); nothing about ${item.to} was verified.`, index: null, introduced: null };
@@ -168,14 +406,26 @@ function planItem(item, lookup) {
168
406
  introduced: null,
169
407
  };
170
408
  }
409
+ if (item.unresolved)
410
+ return { item, blocked: item.unresolved, index: lookup.data, introduced: null };
171
411
  const index = lookup.data;
172
412
  const to = index.releases[item.to];
173
413
  if (!to) {
174
- return { item, blocked: `${item.package}@${item.to} is not a published version on ${item.ecosystem}.`, index, introduced: null };
414
+ return { item, blocked: `${item.package}@${item.to} is not a published version on ${item.ecosystem}.${goMajorHint(item)}`, index, introduced: null };
175
415
  }
176
416
  return { item, blocked: null, index, introduced: introducedDependencies(to, selectBaseline(index, item).record) };
177
417
  }
178
- async function fetchIndexes(items, sources) {
418
+ /** Go majors 2+ live at their own module path. */
419
+ function goMajorHint(item) {
420
+ if (item.ecosystem !== "Go")
421
+ return "";
422
+ const major = goVersionMajor(item.to);
423
+ const pathMajor = goPathMajor(item.package) ?? 1;
424
+ if (major === null || major < 2 || major === pathMajor)
425
+ return "";
426
+ return ` Go major ${major} is a different module: ${item.package.replace(/\/v\d+$/, "")}/v${major}.`;
427
+ }
428
+ async function fetchIndexes(items, sources, fullList) {
179
429
  const wanted = new Map();
180
430
  for (const item of items) {
181
431
  const key = lookupKey(item);
@@ -187,7 +437,9 @@ async function fetchIndexes(items, sources) {
187
437
  }
188
438
  const out = new Map();
189
439
  await mapLimit([...wanted.entries()], NPM_CONCURRENCY, async ([key, { item, versions }]) => {
190
- out.set(key, await sources[item.ecosystem].getReleases(item.package, [...versions]));
440
+ // A reader that pages (crates.io) stops once it has the wanted versions; a constraint needs them all.
441
+ const list = item.ecosystem === "crates.io" && fullList.has(key) ? [] : [...versions];
442
+ out.set(key, await sources[item.ecosystem].getReleases(item.package, list));
191
443
  });
192
444
  return out;
193
445
  }
@@ -198,39 +450,33 @@ function validateItems(raw) {
198
450
  return raw.map((r, i) => {
199
451
  const o = (r ?? {});
200
452
  const where = `items[${i}]`;
201
- if (o.ecosystem !== "npm" && o.ecosystem !== "crates.io")
202
- throw new Error(`${where}.ecosystem must be "npm" or "crates.io"`);
453
+ const ecosystem = ECOSYSTEMS.find((e) => e === o.ecosystem || (typeof o.ecosystem === "string" && e.toLowerCase() === o.ecosystem.trim().toLowerCase()));
454
+ if (!ecosystem)
455
+ throw new Error(`${where}.ecosystem must be one of ${ECOSYSTEMS.map((e) => `"${e}"`).join(", ")}`);
203
456
  if (typeof o.package !== "string" || !o.package.trim())
204
457
  throw new Error(`${where}.package must be a non-empty string`);
205
- const to = typeof o.to === "string" && o.to.trim().toLowerCase() === LATEST ? LATEST : exactVersion(o.to, `${where}.to`);
206
- const from = o.from === undefined || o.from === null || o.from === "" ? undefined : exactVersion(o.from, `${where}.from`);
207
- return { ecosystem: o.ecosystem, package: o.package.trim(), to, ...(from ? { from } : {}) };
458
+ const rawTo = typeof o.to === "string" ? o.to.trim() : "";
459
+ const target = parseTarget(ecosystem, rawTo);
460
+ if (!target) {
461
+ throw new Error(`${where}.to must be an exact version, "latest", or a ${ecosystem} version constraint such as ${EXAMPLES[ecosystem]} (got ${JSON.stringify(o.to)})`);
462
+ }
463
+ let from;
464
+ if (o.from !== undefined && o.from !== null && o.from !== "") {
465
+ const f = typeof o.from === "string" ? parseTarget(ecosystem, o.from) : null;
466
+ if (!f || (f.kind !== "exact" && f.kind !== "exact_or_series")) {
467
+ throw new Error(`${where}.from must be an exact version, the one installed (got ${JSON.stringify(o.from)}), not a range`);
468
+ }
469
+ from = f.version;
470
+ }
471
+ return { ecosystem, package: o.package.trim(), raw: rawTo, target, ...(from ? { from } : {}) };
208
472
  });
209
473
  }
210
- function exactVersion(v, where) {
211
- const s = typeof v === "string" ? v.trim().replace(/^v(?=\d)/, "") : "";
212
- if (!parseSemverPrecedence(s)) {
213
- const latest = where.endsWith(".to") ? ' or "latest"' : "";
214
- throw new Error(`${where} must be an exact version like 1.2.3${latest} (got ${JSON.stringify(v)}), not a range`);
215
- }
216
- return s;
217
- }
218
- const LATEST = "latest";
219
- /**
220
- * `to: "latest"` -> the highest release that is neither a prerelease nor
221
- * withdrawn (a deprecated latest stays: deprecation is a finding to report).
222
- * Left as "latest" when the registry read failed or lists no release; the item
223
- * is then blocked with the registry's own reason, or "not a published version".
224
- */
225
- function resolveLatest(item, lookup) {
226
- if (item.to !== LATEST || lookup.status !== "ok")
227
- return item;
228
- const candidates = Object.values(lookup.data.releases).filter((r) => !r.withdrawn && parseSemverPrecedence(r.version));
229
- const stable = candidates.filter((r) => parseSemverPrecedence(r.version).prerelease.length === 0);
230
- const pool = stable.length > 0 ? stable : candidates;
231
- const newest = pool.reduce((max, r) => (max === null || (compareVersionPrecedence(r.version, max) ?? 0) > 0 ? r.version : max), null);
232
- return newest ? { ...item, to: newest, requested: LATEST } : item;
233
- }
474
+ const EXAMPLES = {
475
+ npm: '"7.x" or "^1.20.0"',
476
+ "crates.io": '"0.17" or "=0.23.x"',
477
+ PyPI: '"3.1.*", "~=3.1" or ">=2,<3"',
478
+ Go: '"v1" or "v0.23.x"',
479
+ };
234
480
  function osvDep(item, version) {
235
481
  return {
236
482
  name: item.package,
@@ -253,8 +499,9 @@ function refs(entries) {
253
499
  return [{ id: v.vulnId, severity: v.severity, summary: v.summary, maintenance: isMaintenanceNotice(v) }];
254
500
  });
255
501
  }
256
- const instanceKey = (eco, name, version) => `${eco}\0${name}\0${version}`;
257
- const lookupKey = (item) => `${item.ecosystem}\0${item.package}`;
502
+ const instanceKey = (eco, name, version) => `${eco}\0${eco === "PyPI" ? normalizePypi(name) : name}\0${version}`;
503
+ const normalizePypi = (name) => name.toLowerCase().replace(/[-_.]+/g, "-");
504
+ const lookupKey = (item) => `${item.ecosystem}\0${item.ecosystem === "PyPI" ? normalizePypi(item.package) : item.package}`;
258
505
  const head = (item) => ({
259
506
  ecosystem: item.ecosystem,
260
507
  package: item.package,
@@ -270,10 +517,10 @@ function finish(checkedAt, results, meta) {
270
517
  const parts = order.filter((v) => counts.has(v)).map((v) => `${counts.get(v)} ${v}`);
271
518
  return {
272
519
  checked_at: checkedAt,
273
- summary: `${results.length} checked: ${parts.join(", ")}. Apply only "proceed" items; report the rest with their evidence.`,
520
+ summary: `${results.length} checked: ${parts.join(", ")}. Write only "proceed" versions; for the rest use range_check's safe versions or report the evidence.`,
274
521
  results,
275
522
  _meta: {
276
- sources: ["registry.npmjs.org", "crates.io", "index.crates.io", "osv.dev"],
523
+ sources: ["registry.npmjs.org", "crates.io", "index.crates.io", "pypi.org", "proxy.golang.org", "osv.dev"],
277
524
  verdict_rule: VERDICT_RULE,
278
525
  privacy: PRIVACY_NOTE,
279
526
  offline: meta.offline,
@@ -41,4 +41,4 @@ export declare const dependencyHealthTool: {
41
41
  };
42
42
  };
43
43
  };
44
- export declare function executeDependencyHealth(_db: FourDADatabase, params: DependencyHealthParams, liveIntel: LiveIntelligence | null): Promise<DependencyHealthResult>;
44
+ export declare function executeDependencyHealth(_db: FourDADatabase | null, params: DependencyHealthParams, liveIntel: LiveIntelligence | null): Promise<DependencyHealthResult>;
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { isActionableVulnerability } from "../live/maintenance.js";
10
10
  import { driftFor } from "./install-drift-notes.js";
11
+ import { discoveryNote } from "../project-tree.js";
11
12
  /** How long a health check waits for its own OSV scan before answering without CVE data (the scan keeps running). */
12
13
  const HEALTH_SCAN_TIMEOUT_MS = 60_000;
13
14
  export const dependencyHealthTool = {
@@ -209,6 +210,10 @@ export async function executeDependencyHealth(_db, params, liveIntel) {
209
210
  if (hidden > 0) {
210
211
  parts.push(`${hidden} advisor${hidden !== 1 ? "ies" : "y"} not counted (platform-inactive or maintenance notices)`);
211
212
  }
213
+ // "all healthy" over a partial walk must say it is partial.
214
+ const partial = discoveryNote(liveIntel.getDiscovery());
215
+ if (partial)
216
+ parts.push(partial.replace(/[.]$/, ""));
212
217
  if (!vulnResult) {
213
218
  parts.push(liveIntel.isEnabled()
214
219
  ? `CVE data not loaded: the OSV scan did not finish within ${HEALTH_SCAN_TIMEOUT_MS / 1000}s or OSV was unreachable. Call again to use it once it completes`
@@ -35,7 +35,7 @@ export declare function rangesContain(ranges: AdvisoryRangeEvent[][], installed:
35
35
  * An advisory naming your dependency is only a gap if you are actually
36
36
  * exposed: the live tool once graded three Hono CVEs `critical` on hono
37
37
  * 4.13.2 when all three are fixed in 4.12.34, a version this repo had already
38
- * pinned past via `pnpm.overrides`.
38
+ * pinned past via pnpm overrides.
39
39
  *
40
40
  * Conservative by construction: a missing or unreadable installed version
41
41
  * counts as AFFECTED. Never claim someone is safe on missing information.
@@ -105,7 +105,7 @@ export function rangesContain(ranges, installed) {
105
105
  * An advisory naming your dependency is only a gap if you are actually
106
106
  * exposed: the live tool once graded three Hono CVEs `critical` on hono
107
107
  * 4.13.2 when all three are fixed in 4.12.34, a version this repo had already
108
- * pinned past via `pnpm.overrides`.
108
+ * pinned past via pnpm overrides.
109
109
  *
110
110
  * Conservative by construction: a missing or unreadable installed version
111
111
  * counts as AFFECTED. Never claim someone is safe on missing information.
@@ -4,10 +4,10 @@
4
4
  * `temporal::{active_repo_roots, dep_within_active_root, shares_active_root}`
5
5
  * and `knowledge_decay::{get_active_project_paths, linked_to}`.
6
6
  */
7
- import type BetterSqlite3 from "better-sqlite3";
7
+ import type { SqliteStatement } from "../sqlite-driver.js";
8
8
  import type { FourDADatabase } from "../db.js";
9
9
  /** `stmt.all(...)`, or [] when the statement cannot run: a read here never throws out of the tool. */
10
- export declare function allRows<T>(stmt: BetterSqlite3.Statement, ...args: unknown[]): T[];
10
+ export declare function allRows<T>(stmt: SqliteStatement, ...args: unknown[]): T[];
11
11
  /** Lowercase, forward slashes, no trailing slash (`knowledge_decay::normalize_project_path`). */
12
12
  export declare function normPath(p: string): string;
13
13
  /**