@4da/mcp-server 5.0.4 → 6.0.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 (172) hide show
  1. package/CHANGELOG.md +667 -0
  2. package/README.md +66 -35
  3. package/dist/build-staleness.d.ts +35 -0
  4. package/dist/build-staleness.js +95 -0
  5. package/dist/db.d.ts +53 -2
  6. package/dist/db.js +233 -37
  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 -1
  158. package/package.json +27 -30
  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
@@ -12,12 +12,20 @@ import { LiveCache } from "./cache.js";
12
12
  import { RateLimiter, DEFAULT_RATE_LIMITS } from "./rate-limiter.js";
13
13
  import { OsvScanner } from "./osv-scanner.js";
14
14
  import { HNFetcher } from "./hn-fetcher.js";
15
- import { resolveAuditVersions, resolveVersions } from "./version-resolver.js";
16
15
  import { NpmRegistry } from "./npm-registry.js";
17
16
  import { CratesRegistry } from "./crates-registry.js";
18
17
  import { PyPIRegistry } from "./pypi-registry.js";
19
18
  import { GoRegistry } from "./go-registry.js";
19
+ import { NpmPackumentReader } from "./npm-packument.js";
20
+ import { CratesVersionsReader } from "./crates-versions.js";
21
+ import { fetchRegistryHealthFor } from "./registry-health.js";
22
+ import { commonPathRoot, dedupeDependencies, emptyVulnResult } from "./dependency-set.js";
23
+ import { groupIsStale, resolveGroup } from "./resolution.js";
24
+ import { applyInstanceDevScope } from "./dev-scope.js";
25
+ /** Minimum gap between vulnerability warmup attempts after an offline result. */
26
+ const WARMUP_RETRY_MS = 60_000;
20
27
  export class LiveIntelligence {
28
+ db;
21
29
  cache;
22
30
  rateLimiter;
23
31
  osvScanner;
@@ -26,14 +34,47 @@ export class LiveIntelligence {
26
34
  cratesRegistry;
27
35
  pypiRegistry;
28
36
  goRegistry;
37
+ npmPackuments;
38
+ cratesVersions;
29
39
  enabled;
30
40
  lastVulnScan = null;
41
+ /** Whether the stored scan included direct devDependencies. */
42
+ lastVulnScanIncludesDev = false;
43
+ /**
44
+ * Order of scan REQUESTS. A scan's result is stored only if no later request
45
+ * has stored one already: the init warmup (runtime scope) used to finish
46
+ * after an agent's `include_dev` scan and replace it, so `upgrade_planner`
47
+ * read a scan without the dev dependencies the agent had just scanned and
48
+ * sent node-fetch 2.6.0 to the ESM-only 3.3.2 with no advisories (2 of 6
49
+ * runs, 2026-10-03 pre-publish verification).
50
+ */
51
+ scanRequests = 0;
52
+ storedScanRequest = 0;
31
53
  lastHeadlines = [];
32
54
  resolvedDeps = [];
33
55
  auditDeps = [];
34
56
  initialized = false;
35
57
  projectRoot = null;
58
+ /** One entry per resolution group: its inputs, result, and the file signatures that say when it went stale. */
59
+ groupResults = [];
60
+ /** When the dependency set in use was assembled: at init, or at the last re-resolution. */
61
+ resolvedAt = null;
62
+ /**
63
+ * Bumped by every re-resolution. A scan started under an older generation
64
+ * describes a dependency set that no longer exists: it may still be handed
65
+ * to the call that asked for it, but it is never stored or served later.
66
+ */
67
+ generation = 0;
68
+ /**
69
+ * The background vulnerability scan started at server init. Tools that need
70
+ * scan data before answering (`what_should_i_know`) await this through
71
+ * `ensureVulnerabilities` instead of reading the cache and finding it empty.
72
+ */
73
+ warmup = null;
74
+ /** When the last warmup came back offline; gates the retry so an offline host is not re-probed on every call. */
75
+ warmupFailedAt = null;
36
76
  constructor(db) {
77
+ this.db = db;
37
78
  this.enabled = process.env.FOURDA_OFFLINE !== "true";
38
79
  this.cache = new LiveCache(db);
39
80
  this.rateLimiter = new RateLimiter(DEFAULT_RATE_LIMITS);
@@ -43,16 +84,15 @@ export class LiveIntelligence {
43
84
  this.cratesRegistry = new CratesRegistry(this.cache, this.rateLimiter);
44
85
  this.pypiRegistry = new PyPIRegistry(this.cache, this.rateLimiter);
45
86
  this.goRegistry = new GoRegistry(this.cache, this.rateLimiter);
87
+ this.npmPackuments = new NpmPackumentReader(this.cache, this.rateLimiter);
88
+ this.cratesVersions = new CratesVersionsReader(this.cache, this.rateLimiter);
46
89
  }
47
90
  /**
48
91
  * Initialize with project data. Call once after project scan.
49
92
  * @deprecated Use initFromMultiEcosystem for correct per-ecosystem resolution.
50
93
  */
51
94
  initFromProject(projectPath, deps, devDeps, language) {
52
- this.resolvedDeps = resolveVersions(projectPath, deps, devDeps, language);
53
- this.auditDeps = resolveAuditVersions(projectPath, deps, devDeps, language);
54
- this.projectRoot = projectPath;
55
- this.initialized = true;
95
+ this.applyGroups([{ dir: projectPath, language, deps, devDeps, targets: {} }], projectPath);
56
96
  }
57
97
  /**
58
98
  * Initialize with per-ecosystem project data. Resolves versions per language
@@ -60,16 +100,22 @@ export class LiveIntelligence {
60
100
  * Call once after project scan.
61
101
  */
62
102
  initFromMultiEcosystem(projectPath, depsByEcosystem, depTargets = {}) {
63
- const allResolved = [];
64
- const allAudit = [];
65
- for (const [language, { deps, devDeps }] of Object.entries(depsByEcosystem)) {
66
- allResolved.push(...resolveVersions(projectPath, deps, devDeps, language, depTargets));
67
- allAudit.push(...resolveAuditVersions(projectPath, deps, devDeps, language, depTargets));
68
- }
69
- this.resolvedDeps = dedupeDependencies(allResolved);
70
- this.auditDeps = dedupeDependencies(allAudit);
71
- this.projectRoot = projectPath;
72
- this.initialized = true;
103
+ const groups = Object.entries(depsByEcosystem).map(([language, { deps, devDeps }]) => ({
104
+ dir: projectPath,
105
+ language,
106
+ deps,
107
+ devDeps,
108
+ targets: depTargets,
109
+ }));
110
+ this.applyGroups(groups, projectPath);
111
+ }
112
+ /**
113
+ * Initialize from every independently-locked project under `root` (see
114
+ * project-tree.ts): each group resolves from its own directory and keeps
115
+ * its manifest's platform targets; `root` is the reported project root.
116
+ */
117
+ initFromProjectTree(root, groups) {
118
+ this.applyGroups(groups, root);
73
119
  }
74
120
  /**
75
121
  * Initialize from dependency groups that each carry their own resolution
@@ -82,17 +128,67 @@ export class LiveIntelligence {
82
128
  * is scanned once.
83
129
  */
84
130
  initFromDependencyGroups(groups) {
85
- const allResolved = [];
86
- const allAudit = [];
87
- for (const { dir, language, deps, devDeps } of groups) {
88
- allResolved.push(...resolveVersions(dir, deps, devDeps, language));
89
- allAudit.push(...resolveAuditVersions(dir, deps, devDeps, language));
90
- }
91
- this.resolvedDeps = dedupeDependencies(allResolved);
92
- this.auditDeps = dedupeDependencies(allAudit);
93
- this.projectRoot = commonPathRoot(groups.map((g) => g.dir));
131
+ // Each resolver stamps `sourceDirs: [dir]` at the point of resolution, so
132
+ // the dedupe can union provenance instead of discarding it.
133
+ this.applyGroups(groups.map((g) => ({ ...g, targets: {} })), commonPathRoot(groups.map((g) => g.dir)));
134
+ }
135
+ applyGroups(groups, projectRoot) {
136
+ this.groupResults = groups.map(resolveGroup);
137
+ this.rebuildDependencySet();
138
+ this.projectRoot = projectRoot;
94
139
  this.initialized = true;
95
140
  }
141
+ rebuildDependencySet() {
142
+ this.resolvedDeps = dedupeDependencies(this.groupResults.flatMap((r) => r.resolved));
143
+ this.auditDeps = dedupeDependencies(this.groupResults.flatMap((r) => r.audit));
144
+ this.resolvedAt = new Date().toISOString();
145
+ }
146
+ /**
147
+ * Re-resolve every group whose inputs changed since they were read (a
148
+ * lockfile rewritten, created or deleted; the fallback manifest; for npm,
149
+ * the node_modules install state), and drop every answer that described the
150
+ * old set: the stored scan and the warmup. Returns true when anything was
151
+ * re-resolved. Stat calls only, so it is cheap enough for the top of every
152
+ * tool call. A process that started before a `git pull` no longer answers
153
+ * for the dependency set it saw at startup.
154
+ */
155
+ refreshIfLockfilesChanged() {
156
+ if (!this.initialized || this.groupResults.length === 0)
157
+ return false;
158
+ let changed = false;
159
+ this.groupResults = this.groupResults.map((result) => {
160
+ if (!groupIsStale(result))
161
+ return result;
162
+ changed = true;
163
+ return resolveGroup(result.group);
164
+ });
165
+ if (!changed)
166
+ return false;
167
+ this.rebuildDependencySet();
168
+ this.generation++;
169
+ this.lastVulnScan = null;
170
+ this.warmup = null;
171
+ this.warmupFailedAt = null;
172
+ return true;
173
+ }
174
+ /** When the dependency set in use was resolved, and from which files. Null before init. */
175
+ getResolutionProvenance() {
176
+ if (!this.initialized || !this.resolvedAt)
177
+ return null;
178
+ const seen = new Set();
179
+ const sources = [];
180
+ for (const result of this.groupResults) {
181
+ if (!result.source || seen.has(result.source.path))
182
+ continue;
183
+ seen.add(result.source.path);
184
+ sources.push(result.source);
185
+ }
186
+ return { resolvedAt: this.resolvedAt, sources };
187
+ }
188
+ /** npm direct dependencies whose installed copy differs from the lockfile, per manifest directory. */
189
+ getInstallDrift() {
190
+ return this.groupResults.flatMap((r) => r.drift);
191
+ }
96
192
  /**
97
193
  * Root directory the dependency set was resolved from, or null before init.
98
194
  * For multi-manifest (grouped) init this is the deepest common ancestor of
@@ -104,31 +200,147 @@ export class LiveIntelligence {
104
200
  }
105
201
  /**
106
202
  * Run vulnerability scan (returns cached if fresh, fetches otherwise).
203
+ *
204
+ * Without `includeDev`, only DIRECT devDependencies are left out (the
205
+ * documented contract). A transitive stays in whatever its scope: an
206
+ * unknown scope must not be hidden, and a known dev-only one is graded
207
+ * down by the shared severity rule, not dropped.
107
208
  */
108
209
  async scanVulnerabilities(projectPath, options) {
109
210
  if (!this.enabled) {
110
211
  return emptyVulnResult(projectPath, true);
111
212
  }
112
- const deps = options?.includeDev
213
+ const scoped = options?.includeDev
113
214
  ? this.auditDeps
114
- : this.auditDeps.filter((d) => !d.isDev);
215
+ : this.auditDeps.filter((d) => !(d.isDirect && d.isDev));
216
+ // Read at scan time, not init time: the app rewrites its inventory on
217
+ // every rescan, and the freshest determination is the one to grade by.
218
+ const deps = applyInstanceDevScope(this.db, scoped);
115
219
  if (deps.length === 0) {
116
220
  return emptyVulnResult(projectPath, false);
117
221
  }
118
222
  if (options?.forceRefresh) {
119
223
  this.cache.invalidateSource("osv");
120
224
  }
225
+ const generation = this.generation;
226
+ const request = ++this.scanRequests;
121
227
  try {
122
- this.lastVulnScan = await this.osvScanner.scan(deps, projectPath);
123
- return this.lastVulnScan;
228
+ const result = await this.osvScanner.scan(deps, projectPath);
229
+ if (generation === this.generation && request > this.storedScanRequest) {
230
+ this.lastVulnScan = result;
231
+ this.lastVulnScanIncludesDev = options?.includeDev ?? false;
232
+ this.storedScanRequest = request;
233
+ }
234
+ return result;
124
235
  }
125
236
  catch {
126
237
  // Network failure — return last known or empty
127
- if (this.lastVulnScan)
238
+ if (generation === this.generation && this.lastVulnScan) {
128
239
  return { ...this.lastVulnScan, offline: true, cached: true };
240
+ }
129
241
  return emptyVulnResult(projectPath, true);
130
242
  }
131
243
  }
244
+ /**
245
+ * Start the vulnerability scan in the background without blocking startup.
246
+ * Never rejects: a failed scan resolves to an empty OFFLINE result so the
247
+ * warmup can be awaited by anyone. Idempotent while a warmup is in flight.
248
+ *
249
+ * Before this existed, full-database mode initialised the dependency set
250
+ * and warmed headlines but never scanned, so `lastVulnScan` stayed null
251
+ * until some tool happened to call `vulnerability_scan` — and the first
252
+ * `what_should_i_know` of a session read an empty cache and reported
253
+ * "safe_to_delegate" for a task naming a package with an open advisory.
254
+ */
255
+ startVulnerabilityWarmup(projectPath) {
256
+ if (this.warmup)
257
+ return;
258
+ this.warmupFailedAt = null;
259
+ this.warmup = this.scanVulnerabilities(projectPath).catch(() => emptyVulnResult(projectPath, true));
260
+ }
261
+ /**
262
+ * A usable vulnerability scan, or null. Re-resolves first when a lockfile
263
+ * changed. Returns the stored scan when one exists, otherwise awaits the
264
+ * warmup (starting one if none is running) for at most `timeoutMs`. Null
265
+ * means the scan is not available: disabled, still running past the
266
+ * deadline, finished offline (an offline scan makes no clean-dependency
267
+ * claims, so it cannot support a "safe" verdict), or overtaken by a
268
+ * re-resolution while it ran. Never throws. A timed-out warmup keeps running
269
+ * so a later call can use it.
270
+ */
271
+ async ensureVulnerabilities(projectPath, timeoutMs, options) {
272
+ if (!this.enabled)
273
+ return null;
274
+ this.refreshIfLockfilesChanged();
275
+ const wantDev = options?.includeDev ?? false;
276
+ if (this.lastVulnScan && !this.lastVulnScan.offline && (!wantDev || this.lastVulnScanIncludesDev)) {
277
+ return this.lastVulnScan;
278
+ }
279
+ // A caller that needs direct devDependencies covered (a briefing whose task
280
+ // names a dev dependency) cannot use the runtime-scope warmup: it reported
281
+ // "vulnerabilities: []" for node-fetch 2.6.0, which has two advisories
282
+ // (2026-10-03 pre-publish verification). Scan at the wider scope, bounded.
283
+ if (wantDev)
284
+ return this.awaitScan(this.scanVulnerabilities(projectPath, { includeDev: true }), timeoutMs);
285
+ if (!this.warmup) {
286
+ if (this.warmupFailedAt !== null && Date.now() - this.warmupFailedAt < WARMUP_RETRY_MS) {
287
+ return null;
288
+ }
289
+ this.startVulnerabilityWarmup(projectPath);
290
+ }
291
+ const pending = this.warmup;
292
+ if (!pending)
293
+ return null;
294
+ const generation = this.generation;
295
+ let timer;
296
+ const deadline = new Promise((resolve) => {
297
+ timer = setTimeout(() => resolve(null), Math.max(0, timeoutMs));
298
+ });
299
+ try {
300
+ const settled = await Promise.race([pending, deadline]);
301
+ if (settled === null)
302
+ return null; // still scanning; keep the warmup alive
303
+ // Re-resolved while waiting: that scan describes a set that no longer exists.
304
+ if (generation !== this.generation)
305
+ return null;
306
+ const result = this.lastVulnScan ?? settled;
307
+ if (result.offline) {
308
+ // Failed or rate-limited: allow a fresh attempt after the backoff.
309
+ this.warmup = null;
310
+ this.warmupFailedAt = Date.now();
311
+ return null;
312
+ }
313
+ return result;
314
+ }
315
+ catch {
316
+ return null;
317
+ }
318
+ finally {
319
+ if (timer !== undefined)
320
+ clearTimeout(timer);
321
+ }
322
+ }
323
+ /** A scan's result within `timeoutMs`, or null when it times out, comes back offline, or is overtaken by a re-resolution. */
324
+ async awaitScan(pending, timeoutMs) {
325
+ const generation = this.generation;
326
+ let timer;
327
+ const deadline = new Promise((resolve) => {
328
+ timer = setTimeout(() => resolve(null), Math.max(0, timeoutMs));
329
+ });
330
+ try {
331
+ const settled = await Promise.race([pending, deadline]);
332
+ if (settled === null || generation !== this.generation || settled.offline)
333
+ return null;
334
+ return settled;
335
+ }
336
+ catch {
337
+ return null;
338
+ }
339
+ finally {
340
+ if (timer !== undefined)
341
+ clearTimeout(timer);
342
+ }
343
+ }
132
344
  /**
133
345
  * Fetch relevant headlines for the user's tech stack.
134
346
  */
@@ -149,6 +361,10 @@ export class LiveIntelligence {
149
361
  getVulnerabilities() {
150
362
  return this.lastVulnScan;
151
363
  }
364
+ /** Whether the stored scan covered direct devDependencies (false when there is none). */
365
+ lastScanIncludesDev() {
366
+ return this.lastVulnScan !== null && this.lastVulnScanIncludesDev;
367
+ }
152
368
  /**
153
369
  * Get last headlines (from cache/memory, no network).
154
370
  */
@@ -171,63 +387,29 @@ export class LiveIntelligence {
171
387
  return this.initialized;
172
388
  }
173
389
  async fetchRegistryHealth(deps) {
174
- if (!this.enabled) {
175
- return deps.map((d) => ({
176
- name: d.name, ecosystem: d.ecosystem, currentVersion: d.version,
177
- latestVersion: null, latestStableVersion: null, versionsBehind: null,
178
- deprecated: false, deprecationMessage: null, lastPublished: null,
179
- license: null, weeklyDownloads: null, isDev: d.isDev, fetchError: "Offline mode",
180
- }));
390
+ // Registries are read at call time (not captured at construction) so a
391
+ // test can substitute one.
392
+ return fetchRegistryHealthFor(deps, { npm: this.npmRegistry, crates: this.cratesRegistry, pypi: this.pypiRegistry, go: this.goRegistry }, this.enabled);
393
+ }
394
+ /** Per-release registry readers for dependency_check (name-only requests). */
395
+ getReleaseSources() {
396
+ return { npm: this.npmPackuments, "crates.io": this.cratesVersions };
397
+ }
398
+ /**
399
+ * OSV advisories for explicit (ecosystem, name, version) triples that need
400
+ * not be in the project, e.g. a version an agent is about to install. Same
401
+ * scanner, cache and rate limit as vulnerability_scan; never touches the
402
+ * stored project scan. Disabled or failed yields an `offline` result.
403
+ */
404
+ async queryAdvisories(deps) {
405
+ if (!this.enabled)
406
+ return emptyVulnResult("dependency_check", true);
407
+ try {
408
+ return await this.osvScanner.scan(deps, "dependency_check");
181
409
  }
182
- const registryForEcosystem = (eco) => {
183
- switch (eco) {
184
- case "npm": return this.npmRegistry;
185
- case "crates.io": return this.cratesRegistry;
186
- case "PyPI": return this.pypiRegistry;
187
- case "Go": return this.goRegistry;
188
- default: return null;
189
- }
190
- };
191
- const results = await Promise.all(deps.map(async (dep) => {
192
- const registry = registryForEcosystem(dep.ecosystem);
193
- if (!registry) {
194
- return {
195
- name: dep.name, ecosystem: dep.ecosystem, currentVersion: dep.version,
196
- latestVersion: null, latestStableVersion: null, versionsBehind: null,
197
- deprecated: false, deprecationMessage: null, lastPublished: null,
198
- license: null, weeklyDownloads: null, isDev: dep.isDev,
199
- fetchError: `No registry fetcher for ${dep.ecosystem}`,
200
- };
201
- }
202
- try {
203
- return await registry.getPackageInfo(dep.name, dep.version, dep.isDev);
204
- }
205
- catch {
206
- return {
207
- name: dep.name, ecosystem: dep.ecosystem, currentVersion: dep.version,
208
- latestVersion: null, latestStableVersion: null, versionsBehind: null,
209
- deprecated: false, deprecationMessage: null, lastPublished: null,
210
- license: null, weeklyDownloads: null, isDev: dep.isDev,
211
- fetchError: "Registry fetch failed",
212
- };
213
- }
214
- }));
215
- // Bulk fetch npm downloads for npm deps
216
- const npmDeps = deps.filter((d) => d.ecosystem === "npm");
217
- if (npmDeps.length > 0) {
218
- try {
219
- const downloads = await this.npmRegistry.getBulkDownloads(npmDeps.map((d) => d.name));
220
- for (const result of results) {
221
- if (result.ecosystem === "npm" && downloads.has(result.name)) {
222
- result.weeklyDownloads = downloads.get(result.name) || null;
223
- }
224
- }
225
- }
226
- catch {
227
- // Downloads are nice-to-have, not critical
228
- }
410
+ catch {
411
+ return emptyVulnResult("dependency_check", true);
229
412
  }
230
- return results;
231
413
  }
232
414
  getStatus() {
233
415
  return {
@@ -240,58 +422,3 @@ export class LiveIntelligence {
240
422
  };
241
423
  }
242
424
  }
243
- /**
244
- * Deepest common ancestor of a set of directories (segment-wise, both slash
245
- * styles). Null for an empty set; a single dir is its own root.
246
- */
247
- function commonPathRoot(dirs) {
248
- if (dirs.length === 0)
249
- return null;
250
- const split = (p) => p.replace(/\\/g, "/").replace(/\/+$/, "").split("/");
251
- let common = split(dirs[0]);
252
- for (const dir of dirs.slice(1)) {
253
- const parts = split(dir);
254
- let i = 0;
255
- while (i < common.length && i < parts.length && common[i].toLowerCase() === parts[i].toLowerCase())
256
- i++;
257
- common = common.slice(0, i);
258
- if (common.length === 0)
259
- break;
260
- }
261
- return common.length > 0 ? common.join("/") : null;
262
- }
263
- function dedupeDependencies(deps) {
264
- const unique = new Map();
265
- for (const dep of deps) {
266
- const key = `${dep.ecosystem}\0${dep.name}\0${dep.version ?? ""}`;
267
- const existing = unique.get(key);
268
- if (existing) {
269
- existing.isDirect ||= dep.isDirect;
270
- existing.devScopeKnown &&= dep.devScopeKnown;
271
- existing.isDev = existing.devScopeKnown && existing.isDev && dep.isDev;
272
- // A crate reachable via ANY active path is active; keep a target label if present.
273
- existing.platformActive ||= dep.platformActive;
274
- existing.target = existing.target ?? dep.target;
275
- }
276
- else {
277
- unique.set(key, { ...dep });
278
- }
279
- }
280
- return [...unique.values()];
281
- }
282
- function emptyVulnResult(projectPath, offline) {
283
- return {
284
- scannedAt: new Date().toISOString(),
285
- projectPath,
286
- ecosystemsScanned: [],
287
- totalScanned: 0,
288
- totalVulnerable: 0,
289
- platformInactiveVulnerable: 0,
290
- bySeverity: { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 },
291
- vulnerabilities: [],
292
- cleanCount: 0,
293
- scanDurationMs: 0,
294
- cached: false,
295
- offline,
296
- };
297
- }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Install drift: what node_modules holds versus what the lockfile pins.
3
+ *
4
+ * Measured 2026-09-10: mcp-4da-server/pnpm-lock.yaml pinned hono 4.13.5 (PR
5
+ * #632, the fix for CVE-2026-84363/-84364/-84365) while
6
+ * mcp-4da-server/node_modules/hono held 4.13.1 for 25 days, because
7
+ * activation ran `pnpm install` only at the repo root. Every 4DA surface
8
+ * reported hono fixed, because every surface read the lockfile. A lockfile
9
+ * says what SHOULD be installed; only node_modules says what IS, and the
10
+ * installed copy is the one that runs.
11
+ */
12
+ import { type FileSignature } from "./file-signature.js";
13
+ import type { InstallDriftRecord, InstallFixCommand, ResolvedDependency } from "./types.js";
14
+ /** The reinstall command for the lockfile a directory resolved from, or null for a non-npm file. */
15
+ export declare function installFixFor(lockfilePath: string): InstallFixCommand | null;
16
+ /**
17
+ * `<d>/node_modules/<name>/package.json` for the nearest `d` in the lookup
18
+ * chain that has it (scoped names included), or null. This is the copy
19
+ * Node's resolver would load from `dir`.
20
+ */
21
+ export declare function findInstalledManifest(dir: string, name: string, chain?: string[]): string | null;
22
+ /** The installed version of `name` as seen from `dir`, or null when not installed or unreadable. Never throws. */
23
+ export declare function readInstalledVersion(dir: string, name: string): string | null;
24
+ export interface InstallCheck {
25
+ /** The direct deps with `installedVersion` filled in; a new array, inputs untouched. */
26
+ resolved: ResolvedDependency[];
27
+ /** One record per direct dep whose installed copy differs from its lockfile version. */
28
+ drift: InstallDriftRecord[];
29
+ /** Audit entries for installed versions the lockfile does not pin, flagged `installDriftOf`. */
30
+ driftAuditDeps: ResolvedDependency[];
31
+ /** Signatures, taken BEFORE reading, of every file whose change means the install state moved. */
32
+ signatures: Map<string, FileSignature>;
33
+ }
34
+ /**
35
+ * Compare each npm direct dependency's lockfile version with node_modules.
36
+ *
37
+ * Skipped entirely when `dir` has no node_modules of its own: a checkout that
38
+ * was never installed has no installed copy to disagree with the lockfile,
39
+ * and borrowing a parent project's node_modules would compare against a
40
+ * different project's install. The node_modules path is still tracked, so an
41
+ * install that creates it is noticed.
42
+ */
43
+ export declare function checkInstallState(dir: string, resolved: ResolvedDependency[], lockfilePath: string): InstallCheck;