@4da/mcp-server 6.2.0 → 6.2.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.2.2 — 2026-10-11
4
+
5
+ ### Lockfile readers agree with the 4DA conformance corpus
6
+
7
+ The dependency engine is now scored in CI against the conformance corpus of
8
+ the 4DA desktop app (35 public repositories and synthetic cases, pinned OSV
9
+ records, adjudicated truth; see `conformance/README.md`), the same truth the
10
+ app's own engine is held to. The first run found five reader defects, all
11
+ fixed. On the corpus, findings and inventory precision and recall are now
12
+ 1.0 in npm, crates.io, PyPI and Go, with no silent drops.
13
+
14
+ - **Go modules before go 1.17 report what is built.** go.sum records the
15
+ go.mod hash of every module version the graph walk visits, but the source
16
+ only of modules the build compiles. A module with only a `/go.mod` line at
17
+ its selected version was reported as installed: 728 never-built modules on
18
+ hugo, node_exporter and traefik, with 182 advisory findings that did not apply. A
19
+ module now counts when go.sum hashes its source at the version minimal
20
+ version selection picks, and `replace` applies to these modules as it does
21
+ to go.mod's own requirements (including `old v1.2.3 => new v1.3.0`).
22
+ - **Versions an advisory lists match by value, not spelling.** OSV lists
23
+ certifi `2020.12.05` (from the git tag) and pip installs `2020.12.5`, the
24
+ same PEP 440 version. The local range check (fix targets, `range_check`,
25
+ `dependency_check`) did not see it as affected by PYSEC-2024-230.
26
+ - **uv and poetry: a git, URL or local source is not a PyPI release.**
27
+ pydantic's uv.lock pins pydantic-extra-types 2.10.0 from git `main`; it was
28
+ scanned as the PyPI release of that number. poetry `[package.source]` types
29
+ `git`, `url`, `file` and `directory` are skipped too; `legacy` (a private
30
+ index) still counts.
31
+ - **yarn: an npm alias is scanned as the package it installs.**
32
+ `"string-width-cjs@npm:string-width@^4.2.0"` was read as a package named
33
+ `string-width-cjs`, which OSV does not know, so string-width 4.2.3 was never
34
+ checked. berry's `resolution:` and v1's alias spec now name the package.
35
+ - **pnpm 5: entries keyed by a tarball URL are read.** pnpm writes `name:`
36
+ and `version:` fields under such a key; trpc's lockfile installs
37
+ @prisma/client 4.7.1 four times that way and none was read. A tarball that
38
+ is not a registry artifact (codeload, a git archive) is still skipped.
39
+
40
+ ## 6.2.1 — 2026-10-11
41
+
42
+ ### Feed tools leave out sources you turned off in the 4DA app
43
+
44
+ Since app schema 126, social and editorial sources (Hacker News, Reddit, RSS
45
+ and the rest) are opt-in "interests", off until turned on in Settings →
46
+ Sources, and every app reader skips their items. The server still returned
47
+ them. `get_relevant_content`, `get_actionable_signals`, `knowledge_gaps` and
48
+ `what_should_i_know` now apply the app's own rule
49
+ (`source_type NOT IN (SELECT source_type FROM sources WHERE enabled = 0)`), and
50
+ say so in a `sources_note` when any source is off. A source with no `sources`
51
+ row counts as on, as in the app; a database without `sources.enabled` is read
52
+ unfiltered, as before. `developer_dna` is unchanged: like the app's own, it
53
+ reports reading history. The vendored app-schema contract moves to schema 127.
54
+
3
55
  ## 6.2.0 — 2026-10-11
4
56
 
5
57
  ### `dependency_check`: the release-age hold no longer leaves only vulnerable choices
package/dist/db.d.ts CHANGED
@@ -257,6 +257,21 @@ export declare class FourDADatabase {
257
257
  /** `MAX(sources.last_fetch)`, tolerant of a missing `sources` table (returns null). */
258
258
  private safeMaxLastFetch;
259
259
  hasTable(table: string): boolean;
260
+ /** Whether `sources.enabled` exists; probed once per connection (see enabledSourceClause). */
261
+ private sourcesEnabledColumn;
262
+ private hasSourcesEnabled;
263
+ /**
264
+ * ` AND <col> is a source the user has not turned off`, or `` when the
265
+ * database cannot say. The desktop app's rule, verbatim
266
+ * (`source_class::enabled_source_sql`, schema 126): every reader of source
267
+ * items skips a `source_type` whose `sources` row has `enabled = 0`. A type
268
+ * with no `sources` row counts as on, as in the app. A database without a
269
+ * `sources.enabled` column (or without `sources`) is read unfiltered, exactly
270
+ * as before this guard existed.
271
+ */
272
+ enabledSourceClause(col: string): string;
273
+ /** Source types the user turned off in the app (`sources.enabled = 0`), sorted; [] when unknown. */
274
+ disabledSources(): string[];
260
275
  hasColumn(table: string, column: string): boolean;
261
276
  /**
262
277
  * Idempotently add a column to a table if it does not already exist.
package/dist/db.js CHANGED
@@ -1033,6 +1033,41 @@ export class FourDADatabase {
1033
1033
  return false;
1034
1034
  }
1035
1035
  }
1036
+ /** Whether `sources.enabled` exists; probed once per connection (see enabledSourceClause). */
1037
+ sourcesEnabledColumn;
1038
+ hasSourcesEnabled() {
1039
+ if (this.sourcesEnabledColumn === undefined) {
1040
+ this.sourcesEnabledColumn = this.hasColumn("sources", "enabled");
1041
+ }
1042
+ return this.sourcesEnabledColumn;
1043
+ }
1044
+ /**
1045
+ * ` AND <col> is a source the user has not turned off`, or `` when the
1046
+ * database cannot say. The desktop app's rule, verbatim
1047
+ * (`source_class::enabled_source_sql`, schema 126): every reader of source
1048
+ * items skips a `source_type` whose `sources` row has `enabled = 0`. A type
1049
+ * with no `sources` row counts as on, as in the app. A database without a
1050
+ * `sources.enabled` column (or without `sources`) is read unfiltered, exactly
1051
+ * as before this guard existed.
1052
+ */
1053
+ enabledSourceClause(col) {
1054
+ return this.hasSourcesEnabled()
1055
+ ? ` AND ${col} NOT IN (SELECT source_type FROM sources WHERE enabled = 0)`
1056
+ : ``;
1057
+ }
1058
+ /** Source types the user turned off in the app (`sources.enabled = 0`), sorted; [] when unknown. */
1059
+ disabledSources() {
1060
+ if (!this.hasSourcesEnabled())
1061
+ return [];
1062
+ try {
1063
+ return this.db
1064
+ .prepare("SELECT source_type FROM sources WHERE enabled = 0 ORDER BY source_type")
1065
+ .all().map((r) => r.source_type);
1066
+ }
1067
+ catch {
1068
+ return [];
1069
+ }
1070
+ }
1036
1071
  hasColumn(table, column) {
1037
1072
  try {
1038
1073
  const cols = this.db.pragma(`table_info(${table})`);
@@ -1129,7 +1164,7 @@ export class FourDADatabase {
1129
1164
  ${depCount} AS dep_match_count
1130
1165
  FROM source_items
1131
1166
  WHERE relevance_score >= ?
1132
- AND datetime(created_at) >= datetime(?)${versionGuard}${curationGuard}${advisoryFreshnessGuard}
1167
+ AND datetime(created_at) >= datetime(?)${versionGuard}${curationGuard}${advisoryFreshnessGuard}${this.enabledSourceClause("source_type")}
1133
1168
  `;
1134
1169
  const params = [minScore, sinceDate];
1135
1170
  if (sourceType) {
@@ -1213,7 +1248,7 @@ export class FourDADatabase {
1213
1248
  let query = `
1214
1249
  SELECT id, source_type, source_id, url, title, content, content_hash, created_at, last_seen
1215
1250
  FROM source_items
1216
- WHERE datetime(created_at) >= datetime(?)${advisoryFreshnessGuard}
1251
+ WHERE datetime(created_at) >= datetime(?)${advisoryFreshnessGuard}${this.enabledSourceClause("source_type")}
1217
1252
  `;
1218
1253
  const params = [sinceDate];
1219
1254
  if (sourceType) {
@@ -8,6 +8,7 @@
8
8
  * Set FOURDA_OFFLINE=true to disable all network calls.
9
9
  */
10
10
  import type { SqliteDatabase } from "../sqlite-driver.js";
11
+ import { type AdvisorySource } from "./osv-scanner.js";
11
12
  import { FixPathSources } from "./fix-paths.js";
12
13
  import type { CheckEcosystem, ReleaseMetadataSource } from "./release-metadata.js";
13
14
  import { type ResolutionGroup } from "./resolution.js";
@@ -70,7 +71,14 @@ export declare class LiveIntelligence {
70
71
  private warmup;
71
72
  /** When the last warmup came back offline; gates the retry so an offline host is not re-probed on every call. */
72
73
  private warmupFailedAt;
73
- constructor(db: SqliteDatabase | null);
74
+ /**
75
+ * `advisorySource` replaces OSV.dev for vulnerability scans (the conformance
76
+ * harness passes pinned records). A source that needs no network keeps
77
+ * scanning enabled under FOURDA_OFFLINE; nothing else changes.
78
+ */
79
+ constructor(db: SqliteDatabase | null, options?: {
80
+ advisorySource?: AdvisorySource;
81
+ });
74
82
  /** Advisory, registry and parent-requirement lookups for fix paths; null when offline. */
75
83
  getFixPathSources(): FixPathSources | null;
76
84
  /**
@@ -84,12 +84,17 @@ export class LiveIntelligence {
84
84
  warmup = null;
85
85
  /** When the last warmup came back offline; gates the retry so an offline host is not re-probed on every call. */
86
86
  warmupFailedAt = null;
87
- constructor(db) {
87
+ /**
88
+ * `advisorySource` replaces OSV.dev for vulnerability scans (the conformance
89
+ * harness passes pinned records). A source that needs no network keeps
90
+ * scanning enabled under FOURDA_OFFLINE; nothing else changes.
91
+ */
92
+ constructor(db, options = {}) {
88
93
  this.db = db;
89
- this.enabled = process.env.FOURDA_OFFLINE !== "true";
94
+ this.enabled = process.env.FOURDA_OFFLINE !== "true" || options.advisorySource?.network === false;
90
95
  this.cache = new LiveCache(db);
91
96
  this.rateLimiter = new RateLimiter(DEFAULT_RATE_LIMITS);
92
- this.osvScanner = new OsvScanner(this.cache, this.rateLimiter);
97
+ this.osvScanner = new OsvScanner(this.cache, this.rateLimiter, options.advisorySource);
93
98
  this.hnFetcher = new HNFetcher(this.cache, this.rateLimiter);
94
99
  this.npmRegistry = new NpmRegistry(this.cache, this.rateLimiter);
95
100
  this.cratesRegistry = new CratesRegistry(this.cache, this.rateLimiter);
@@ -57,6 +57,65 @@ export function parsePnpmKey(rawKey) {
57
57
  }
58
58
  return null;
59
59
  }
60
+ function yamlScalar(text, key) {
61
+ if (!text.startsWith(`${key}:`))
62
+ return null;
63
+ const value = unquote(text.slice(key.length + 1).trim()).trim();
64
+ return value === "" ? null : value;
65
+ }
66
+ function pnpmEntryField(entry, text) {
67
+ entry.inResolution = false;
68
+ const dev = /^dev:\s*(true|false)\s*$/.exec(text);
69
+ const name = yamlScalar(text, "name");
70
+ const version = yamlScalar(text, "version");
71
+ if (dev)
72
+ entry.dev = dev[1] === "true";
73
+ else if (name)
74
+ entry.name = name;
75
+ else if (version)
76
+ entry.version = exactVersion(version) ?? undefined;
77
+ else if (text.startsWith("resolution:")) {
78
+ entry.inResolution = true;
79
+ pnpmResolutionText(entry, text.slice("resolution:".length));
80
+ }
81
+ }
82
+ /** `resolution:` is a flow mapping, on one line or (pnpm 5) spread over several. */
83
+ function pnpmResolutionText(entry, text) {
84
+ for (const part of text.split(/[,{}]/)) {
85
+ const field = part.trim();
86
+ if (field.startsWith("tarball:"))
87
+ entry.tarball = unquote(field.slice("tarball:".length).trim());
88
+ else if (/^(?:commit|repo|directory):/.test(field))
89
+ entry.local = true;
90
+ }
91
+ }
92
+ /** A registry tarball (`https://<registry>/<name>/-/<name>-<version>.tgz`, query allowed), not a codeload/git/arbitrary archive. */
93
+ function isRegistryTarball(url) {
94
+ const pathPart = url.split(/[?#]/)[0];
95
+ return pathPart.includes("/-/") && /\.tgz$/i.test(pathPart);
96
+ }
97
+ /**
98
+ * The (name, version) a pnpm entry installs from a registry, or null.
99
+ *
100
+ * The key is not always `name@version`: pnpm 5 keys a package fetched by
101
+ * tarball URL by that URL and writes `name:`/`version:` fields under it.
102
+ * trpc's lockfile installs @prisma/client 4.7.1 four times that way, from
103
+ * registry.npmjs.com, and none of them was read (4DA conformance corpus,
104
+ * 2026-10-12). The fields win when present; a tarball that is not a registry
105
+ * artifact, a git checkout or a directory is not a registry release.
106
+ */
107
+ function pnpmEntryIdentity(entry) {
108
+ if (entry.local)
109
+ return null;
110
+ const parsed = parsePnpmKey(entry.key);
111
+ if (entry.name && PACKAGE_NAME.test(entry.name)) {
112
+ if (entry.tarball && !isRegistryTarball(entry.tarball))
113
+ return null;
114
+ const version = entry.version ?? parsed?.[1];
115
+ return version ? [entry.name, version] : null;
116
+ }
117
+ return parsed;
118
+ }
60
119
  /**
61
120
  * Reads a pnpm lockfile (v5.x, v6 or v9) into `versions`; the first version seen wins.
62
121
  *
@@ -80,30 +139,38 @@ export function readPnpmLock(content, versions, instances) {
80
139
  let section = "";
81
140
  let blockIndent = -1; // column of the dependency block being read, or -1
82
141
  let pending = null; // v6/v9: a name whose `version:` line follows
83
- let packageKey = null; // the packages/snapshots key whose fields are being read
142
+ let entry = null; // the packages/snapshots entry whose fields are being read
143
+ const finish = () => {
144
+ const id = entry ? pnpmEntryIdentity(entry) : null;
145
+ if (id) {
146
+ transitive.push(id);
147
+ if (entry.dev !== undefined)
148
+ devByKey.set(`${id[0]}\0${id[1]}`, entry.dev);
149
+ }
150
+ entry = null;
151
+ };
84
152
  for (const line of content.split(/\r?\n/)) {
85
153
  const text = line.trim();
86
154
  if (text === "" || text.startsWith("#"))
87
155
  continue;
88
156
  const indent = line.length - line.trimStart().length;
89
157
  if (indent === 0) {
158
+ finish();
90
159
  section = text.endsWith(":") ? text.slice(0, -1) : "";
91
160
  blockIndent = DEPENDENCY_BLOCKS.has(text) ? 0 : -1;
92
161
  pending = null;
93
- packageKey = null;
94
162
  continue;
95
163
  }
96
164
  if (section === "packages" || section === "snapshots") {
97
165
  if (indent === 2 && text.endsWith(":")) {
98
- const parsed = parsePnpmKey(text.slice(0, -1));
99
- packageKey = parsed ? `${parsed[0]}\0${parsed[1]}` : null;
100
- if (parsed)
101
- transitive.push(parsed);
166
+ finish();
167
+ entry = { key: text.slice(0, -1), inResolution: false, local: false };
102
168
  }
103
- else if (indent === 4 && packageKey) {
104
- const dev = /^dev:\s*(true|false)\s*$/.exec(text);
105
- if (dev)
106
- devByKey.set(packageKey, dev[1] === "true");
169
+ else if (entry && indent === 4) {
170
+ pnpmEntryField(entry, text);
171
+ }
172
+ else if (entry?.inResolution && indent > 4) {
173
+ pnpmResolutionText(entry, text);
107
174
  }
108
175
  continue;
109
176
  }
@@ -137,6 +204,7 @@ export function readPnpmLock(content, versions, instances) {
137
204
  pending = null;
138
205
  }
139
206
  }
207
+ finish();
140
208
  for (const [name, version] of transitive)
141
209
  setFirst(name, version);
142
210
  if (!instances)
@@ -148,6 +216,21 @@ export function readPnpmLock(content, versions, instances) {
148
216
  for (const [name, version] of transitive)
149
217
  instances.add(name, version, devByKey.get(`${name}\0${version}`));
150
218
  }
219
+ /**
220
+ * The registry package a yarn block installs. An npm alias
221
+ * (`"string-width-cjs": "npm:string-width@^4.2.0"`) keys the block by the
222
+ * alias, which is not a package: OSV knows `string-width`, so the alias was
223
+ * queried for nothing and the real package went unscanned (hono's
224
+ * benchmarks/routers yarn.lock, 4DA conformance corpus, 2026-10-12). berry
225
+ * names the real package in `resolution:`; v1 only in the alias spec.
226
+ */
227
+ function yarnPackageName(block, firstLine, headerName) {
228
+ const resolution = /^\s+resolution:\s+"?((?:@[^\s/@"]+\/)?[^\s/@"]+)@npm:/m.exec(block);
229
+ if (resolution)
230
+ return resolution[1];
231
+ const alias = /^"?(?:@[^\s/@"]+\/)?[^\s/@"]+@npm:((?:@[^\s/@"]+\/)?[^\s/@"]+)@/.exec(firstLine);
232
+ return alias ? alias[1] : headerName;
233
+ }
151
234
  /**
152
235
  * Reads a yarn lockfile (v1 or berry) into `versions`.
153
236
  * v1: `"@scope/pkg@^1.0.0", "@scope/pkg@^1.1.0":` then ` version "1.2.3"`.
@@ -166,12 +249,13 @@ export function readYarnLock(content, versions, instances, specs) {
166
249
  const version = line ? exactVersion(line[1]) : null;
167
250
  if (!version)
168
251
  continue;
252
+ const name = yarnPackageName(block, firstLine, header[1]);
169
253
  // One block per resolved version. Blocks are sorted by spec, so the first
170
254
  // block of a name can be an old nested copy; `specs` lets the caller pick
171
255
  // the copy a package.json range resolves to instead.
172
- if (!versions.has(header[1]))
173
- versions.set(header[1], version);
174
- instances?.add(header[1], version);
256
+ if (!versions.has(name))
257
+ versions.set(name, version);
258
+ instances?.add(name, version);
175
259
  if (specs) {
176
260
  for (const spec of firstLine.replace(/:\s*$/, "").split(",")) {
177
261
  specs.set(unquote(spec.trim()).replace(/@npm:/, "@"), version);
@@ -69,23 +69,53 @@ export declare function parseRequirements(content: string): Array<{
69
69
  version: string;
70
70
  }>;
71
71
  export declare function resolveGo(cwd: string): VersionSource;
72
- /** `require` lines (block and single form) with `replace` applied, and whether the go directive is >= 1.17. */
72
+ /** A `replace` target: another module version, or null for a local directory. */
73
+ type GoReplacement = {
74
+ module: string;
75
+ version: string;
76
+ } | null;
77
+ /**
78
+ * `require` lines (block and single form) with `replace` applied, the same
79
+ * lines as written, `replace` for any other module version, and whether the
80
+ * go directive is >= 1.17. A `replace` may name the version it replaces
81
+ * (`old v1.2.3 => new v1.3.0`); that one wins over a versionless one.
82
+ */
73
83
  export declare function parseGoMod(content: string): {
74
84
  requires: Array<{
75
85
  module: string;
76
86
  version: string;
77
87
  indirect: boolean;
78
88
  }>;
89
+ /** Requirements as written, before `replace`. */
90
+ rawRequires: Array<{
91
+ module: string;
92
+ version: string;
93
+ indirect: boolean;
94
+ }>;
95
+ /** What `replace` maps a module version to: undefined for nothing, null for local code. */
96
+ replacement: (module: string, version: string) => GoReplacement | undefined;
79
97
  goVersionAtLeast117: boolean;
80
98
  };
81
99
  /**
82
- * Per module, the highest version go.sum records — minimal version selection
83
- * picks the highest required version, and go.sum records every version the
84
- * module graph required. A module listed only by its `/go.mod` hash is still
85
- * in Go's build list (`go list -m all`): measured 2026-10-02 on gh cli v1.0
86
- * (go 1.13), skipping those lines missed 14 advisories (grpc 1.21.0,
87
- * jwt-go, gorilla/websocket, ...) the build list carries.
100
+ * Per module, the version the build selected according to go.sum (go < 1.17):
101
+ * the HIGHEST version recorded there by either line, since minimal version
102
+ * selection picks the highest version the module graph requires and go.sum
103
+ * records every one, kept only when go.sum also hashes that version's SOURCE
104
+ * (`module v1.2.3 h1:...`, not only `module v1.2.3/go.mod h1:...`).
105
+ *
106
+ * The go command records the go.mod hash of every module version it visits
107
+ * while walking the graph, but downloads and hashes the source only of
108
+ * modules whose packages the build imports. A module with only a `/go.mod`
109
+ * line at its selected version is in `go list -m all` but is never compiled.
110
+ * Counting those (the 6.2 rule) read 728 never-built modules as installed
111
+ * on hugo, node_exporter and traefik: 4DA conformance corpus, 2026-10-12,
112
+ * whose truth is the build list restricted to source-hashed modules, as the
113
+ * desktop app reads it. An older version's leftover source hash does not make
114
+ * that older version built either: node_exporter hashes gogo/protobuf
115
+ * v1.1.1's source while the graph selects v1.2.1 (go.mod hash only), so
116
+ * neither is installed.
88
117
  */
89
118
  export declare function selectedFromGoSum(content: string): Map<string, string>;
90
119
  /** Semver precedence over Go versions; build metadata (`+incompatible`) is ignored. */
91
120
  export declare function compareGoVersions(a: string, b: string): number;
121
+ export {};
@@ -226,6 +226,24 @@ function unpinnedRequirementNames(content) {
226
226
  }
227
227
  return out;
228
228
  }
229
+ /**
230
+ * Whether a lockfile [[package]] entry is a release from a package index.
231
+ * Only those are what PyPI advisories describe. Not one: the project itself
232
+ * and local code (uv `editable`/`virtual`/`directory`/`path`), a git checkout
233
+ * or a direct-URL archive (uv `git`/`url`; poetry `[package.source] type =
234
+ * "git" | "url" | "file" | "directory"`). A git checkout keeps the version
235
+ * number of a release it is not: pydantic's uv.lock pins
236
+ * pydantic-extra-types 2.10.0 from git `main` (4DA conformance corpus).
237
+ * poetry's `legacy` source is a private index, so its packages are releases.
238
+ */
239
+ function fromPackageIndex(block, body) {
240
+ const uv = /^source\s*=\s*\{\s*([a-z-]+)\s*=/m.exec(body)?.[1];
241
+ if (uv && uv !== "registry")
242
+ return false;
243
+ const poetrySource = /^\[package\.source\]\s*$([\s\S]*?)(?=^\[|(?![\s\S]))/m.exec(block)?.[1] ?? "";
244
+ const poetryType = /^type\s*=\s*"([^"]*)"/m.exec(poetrySource)?.[1];
245
+ return !(poetryType && ["git", "url", "file", "directory"].includes(poetryType));
246
+ }
229
247
  /**
230
248
  * [[package]] tables from poetry.lock (any version) or uv.lock. A pre-1.2
231
249
  * poetry.lock records `category = "dev"` for dev-only packages; newer files
@@ -242,8 +260,7 @@ export function readTomlPackageLock(content) {
242
260
  const version = field("version");
243
261
  if (!name || !version)
244
262
  continue;
245
- // uv.lock lists the project itself (source = { editable/virtual = "." }); it is not a release.
246
- if (/^source\s*=\s*\{\s*(?:editable|virtual)\s*=/m.test(body))
263
+ if (!fromPackageIndex(block, body))
247
264
  continue;
248
265
  const category = field("category");
249
266
  const normalized = normalizePythonName(name);
@@ -294,35 +311,47 @@ export function resolveGo(cwd) {
294
311
  catch {
295
312
  return emptySource();
296
313
  }
297
- const versions = new Map();
298
- const instances = new InstanceSet();
299
- for (const { module, version } of mod.requires) {
300
- versions.set(module, version);
301
- instances.add(module, version);
302
- }
314
+ // The build list: go.mod's requirements and, before go 1.17, the modules
315
+ // go.sum says the build selected; `replace` applies to both.
316
+ const selected = new Map(mod.rawRequires.map((r) => [r.module, r.version]));
303
317
  // From go 1.17 the go.mod `require` list names every module the build needs
304
318
  // (module-graph pruning), so it IS the selected set and go.sum (which keeps
305
319
  // hashes for versions the build never selects) would only over-report.
306
320
  // Before 1.17 go.mod lists direct requirements only; transitives come from
307
- // go.sum, at the highest version whose module zip is hashed there — what
308
- // minimal version selection picked.
321
+ // go.sum (selectedFromGoSum: the version MVS picked, when its source is built).
309
322
  if (!mod.goVersionAtLeast117) {
310
323
  const goSumPath = path.join(cwd, "go.sum");
311
324
  if (fs.existsSync(goSumPath)) {
312
325
  try {
313
326
  for (const [module, version] of selectedFromGoSum(fs.readFileSync(goSumPath, "utf-8"))) {
314
- if (versions.has(module))
315
- continue;
316
- versions.set(module, version);
317
- instances.add(module, version);
327
+ if (!selected.has(module))
328
+ selected.set(module, version);
318
329
  }
319
330
  }
320
331
  catch { /* skip */ }
321
332
  }
322
333
  }
334
+ const versions = new Map();
335
+ for (const [module, version] of selected) {
336
+ const target = mod.replacement(module, version);
337
+ if (target === null)
338
+ continue; // replaced by a local directory: local code, not a registry module
339
+ if (target)
340
+ versions.set(target.module, target.version);
341
+ else if (!versions.has(module))
342
+ versions.set(module, version);
343
+ }
344
+ const instances = new InstanceSet();
345
+ for (const [module, version] of versions)
346
+ instances.add(module, version);
323
347
  return found(versions, instances, goModPath, "lockfile");
324
348
  }
325
- /** `require` lines (block and single form) with `replace` applied, and whether the go directive is >= 1.17. */
349
+ /**
350
+ * `require` lines (block and single form) with `replace` applied, the same
351
+ * lines as written, `replace` for any other module version, and whether the
352
+ * go directive is >= 1.17. A `replace` may name the version it replaces
353
+ * (`old v1.2.3 => new v1.3.0`); that one wins over a versionless one.
354
+ */
326
355
  export function parseGoMod(content) {
327
356
  const requires = new Map();
328
357
  const replaces = new Map();
@@ -354,42 +383,68 @@ export function parseGoMod(content) {
354
383
  const [left, right] = rest.split("=>").map((s) => s.trim().split(/\s+/));
355
384
  if (!left?.[0] || !right?.[0])
356
385
  continue;
357
- replaces.set(left[0], right[1] && GO_VERSION.test(right[1]) ? { module: right[0], version: right[1] } : null);
386
+ const key = left[1] ? `${left[0]} ${left[1]}` : left[0];
387
+ replaces.set(key, right[1] && GO_VERSION.test(right[1]) ? { module: right[0], version: right[1] } : null);
358
388
  }
359
389
  }
390
+ const replacement = (module, version) => {
391
+ const exact = `${module} ${version}`;
392
+ if (replaces.has(exact))
393
+ return replaces.get(exact);
394
+ return replaces.has(module) ? replaces.get(module) : undefined;
395
+ };
396
+ const rawRequires = [...requires.values()];
360
397
  const result = [];
361
- for (const req of requires.values()) {
362
- if (!replaces.has(req.module)) {
398
+ for (const req of rawRequires) {
399
+ const target = replacement(req.module, req.version);
400
+ if (target === undefined)
363
401
  result.push(req);
364
- continue;
365
- }
366
- const target = replaces.get(req.module);
367
- if (target)
402
+ else if (target)
368
403
  result.push({ module: target.module, version: target.version, indirect: req.indirect });
369
404
  }
370
405
  const go = /^go\s+(\d+)\.(\d+)/m.exec(content);
371
406
  const goVersionAtLeast117 = go ? Number(go[1]) > 1 || Number(go[2]) >= 17 : false;
372
- return { requires: result, goVersionAtLeast117 };
407
+ return { requires: result, rawRequires, replacement, goVersionAtLeast117 };
373
408
  }
374
409
  /**
375
- * Per module, the highest version go.sum records — minimal version selection
376
- * picks the highest required version, and go.sum records every version the
377
- * module graph required. A module listed only by its `/go.mod` hash is still
378
- * in Go's build list (`go list -m all`): measured 2026-10-02 on gh cli v1.0
379
- * (go 1.13), skipping those lines missed 14 advisories (grpc 1.21.0,
380
- * jwt-go, gorilla/websocket, ...) the build list carries.
410
+ * Per module, the version the build selected according to go.sum (go < 1.17):
411
+ * the HIGHEST version recorded there by either line, since minimal version
412
+ * selection picks the highest version the module graph requires and go.sum
413
+ * records every one, kept only when go.sum also hashes that version's SOURCE
414
+ * (`module v1.2.3 h1:...`, not only `module v1.2.3/go.mod h1:...`).
415
+ *
416
+ * The go command records the go.mod hash of every module version it visits
417
+ * while walking the graph, but downloads and hashes the source only of
418
+ * modules whose packages the build imports. A module with only a `/go.mod`
419
+ * line at its selected version is in `go list -m all` but is never compiled.
420
+ * Counting those (the 6.2 rule) read 728 never-built modules as installed
421
+ * on hugo, node_exporter and traefik: 4DA conformance corpus, 2026-10-12,
422
+ * whose truth is the build list restricted to source-hashed modules, as the
423
+ * desktop app reads it. An older version's leftover source hash does not make
424
+ * that older version built either: node_exporter hashes gogo/protobuf
425
+ * v1.1.1's source while the graph selects v1.2.1 (go.mod hash only), so
426
+ * neither is installed.
381
427
  */
382
428
  export function selectedFromGoSum(content) {
383
- const selected = new Map();
429
+ const highest = new Map();
430
+ const sourceHashed = new Set();
384
431
  for (const line of content.split(/\r?\n/)) {
385
432
  const [module, rawVersion] = line.trim().split(/\s+/);
386
- const version = rawVersion?.replace(/\/go\.mod$/, "");
387
- if (!module || !version || !GO_VERSION.test(version))
433
+ if (!module || !rawVersion)
434
+ continue;
435
+ const version = rawVersion.replace(/\/go\.mod$/, "");
436
+ if (!GO_VERSION.test(version))
388
437
  continue;
389
- const current = selected.get(module);
438
+ if (version === rawVersion)
439
+ sourceHashed.add(`${module} ${version}`);
440
+ const current = highest.get(module);
390
441
  if (!current || compareGoVersions(version, current) > 0)
391
- selected.set(module, version);
442
+ highest.set(module, version);
392
443
  }
444
+ const selected = new Map();
445
+ for (const [module, version] of highest)
446
+ if (sourceHashed.has(`${module} ${version}`))
447
+ selected.set(module, version);
393
448
  return selected;
394
449
  }
395
450
  /** Semver precedence over Go versions; build metadata (`+incompatible`) is ignored. */
@@ -41,7 +41,14 @@ export interface AdvisoryRanges {
41
41
  export declare function collectAffectedSpans(affected: OsvVulnerability["affected"] | undefined, packageName: string, ecosystem: string): AffectedSpan[];
42
42
  /** One advisory's ranges for one package; null when the advisory is withdrawn. */
43
43
  export declare function advisoryRanges(vuln: OsvVulnerability, packageName: string, ecosystem: string): AdvisoryRanges | null;
44
- /** True when `version` lies in a span (or the explicit list) of this advisory. */
44
+ /**
45
+ * True when `version` lies in a span (or the explicit list) of this advisory.
46
+ *
47
+ * A listed version matches when it is the same version in the ecosystem's
48
+ * order, not only the same string: OSV lists certifi `2020.12.05` (from the
49
+ * git tag) and pip installs `2020.12.5`, the same PEP 440 version, which OSV
50
+ * reports as affected by PYSEC-2024-230 (4DA conformance corpus, 2026-10-12).
51
+ */
45
52
  export declare function advisoryAffects(advisory: AdvisoryRanges, version: string, ecosystem: string): boolean;
46
53
  /** Whether a version is a prerelease in its ecosystem's own terms. */
47
54
  export declare function isPrereleaseVersion(version: string, ecosystem: string): boolean;
@@ -78,11 +78,21 @@ export function advisoryRanges(vuln, packageName, ecosystem) {
78
78
  maintenance: isMaintenanceNotice({ summary: vuln.summary ?? "" }) || informational === "unmaintained" || informational === "notice",
79
79
  };
80
80
  }
81
- /** True when `version` lies in a span (or the explicit list) of this advisory. */
81
+ /**
82
+ * True when `version` lies in a span (or the explicit list) of this advisory.
83
+ *
84
+ * A listed version matches when it is the same version in the ecosystem's
85
+ * order, not only the same string: OSV lists certifi `2020.12.05` (from the
86
+ * git tag) and pip installs `2020.12.5`, the same PEP 440 version, which OSV
87
+ * reports as affected by PYSEC-2024-230 (4DA conformance corpus, 2026-10-12).
88
+ */
82
89
  export function advisoryAffects(advisory, version, ecosystem) {
83
90
  if (advisory.versions.includes(version))
84
91
  return true;
85
92
  const cmp = (a, b) => compareVersions(a, b, ecosystem);
93
+ if (isComparable(version, ecosystem) && advisory.versions.some((v) => v !== "0" && isComparable(v, ecosystem) && cmp(v, version) === 0)) {
94
+ return true;
95
+ }
86
96
  return advisory.spans.some((s) => cmp(version, s.introduced) >= 0 &&
87
97
  (s.fixed !== null ? cmp(version, s.fixed) < 0 : s.lastAffected !== null ? cmp(version, s.lastAffected) <= 0 : true));
88
98
  }
@@ -9,22 +9,49 @@
9
9
  import type { LiveCache } from "./cache.js";
10
10
  import type { RateLimiter } from "./rate-limiter.js";
11
11
  import type { ResolvedDependency, OsvVulnerability, VulnerabilityEntry, VulnerabilityScanResult } from "./types.js";
12
- export declare class OsvScanner {
13
- private cache;
14
- private rateLimiter;
12
+ /**
13
+ * Where advisories come from. The scanner asks which advisory ids affect each
14
+ * exact (ecosystem, name, version), then for the records behind those ids.
15
+ *
16
+ * - OsvHttpSource (the server's): OSV.dev's own evaluator decides membership
17
+ * (`/v1/querybatch`), records come from `/v1/vulns/{id}`, both cached.
18
+ * - PinnedRecordsSource (pinned-advisories.ts): a fixed set of records on
19
+ * disk, membership decided locally by osv-ranges.ts. No network. The
20
+ * conformance harness scores the engine with it against the 4DA corpus.
21
+ */
22
+ export interface AdvisorySource {
23
+ /** True when answers come over the network: rate-limited, and cached per dependency. */
24
+ readonly network: boolean;
25
+ /** The advisory ids affecting each dependency, in input order. Throws when it cannot answer for all of them. */
26
+ query(deps: ResolvedDependency[]): Promise<string[][]>;
27
+ /** The full record of each id it can find; a missing id is left out, never fatal. */
28
+ details(ids: string[]): Promise<Map<string, OsvVulnerability>>;
29
+ }
30
+ /** OSV.dev over HTTP: querybatch for membership, per-id hydration for records. */
31
+ export declare class OsvHttpSource implements AdvisorySource {
32
+ private readonly cache;
33
+ private readonly rateLimiter;
34
+ readonly network = true;
15
35
  constructor(cache: LiveCache, rateLimiter: RateLimiter);
16
- scan(deps: ResolvedDependency[], projectPath: string): Promise<VulnerabilityScanResult>;
17
- private batchQuery;
36
+ query(deps: ResolvedDependency[]): Promise<string[][]>;
18
37
  /**
19
38
  * Hydrate advisory detail for the given IDs. Cache-first (24h), then fetch the
20
39
  * misses from `/v1/vulns/{id}` with bounded concurrency. A failed fetch is
21
40
  * skipped (not fatal) — the caller falls back to the index-only shape so a
22
41
  * real advisory is never silently dropped just because its detail 404'd.
23
42
  */
24
- private hydrateDetails;
43
+ details(ids: string[]): Promise<Map<string, OsvVulnerability>>;
25
44
  /** One advisory's record; retried once after a throttle, a server error or a network failure. Null when it cannot be had. */
26
45
  private fetchDetail;
27
46
  }
47
+ export declare class OsvScanner {
48
+ private cache;
49
+ private rateLimiter;
50
+ private source;
51
+ constructor(cache: LiveCache, rateLimiter: RateLimiter, source?: AdvisorySource);
52
+ scan(deps: ResolvedDependency[], projectPath: string): Promise<VulnerabilityScanResult>;
53
+ private batchQuery;
54
+ }
28
55
  /**
29
56
  * Overwrite the dep-context fields of a cached advisory entry with the CURRENT
30
57
  * resolution's context. The advisory facts (id, severity, summary, fix version)
@@ -49,79 +49,18 @@ async function pool(items, limit, work) {
49
49
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
50
50
  return out;
51
51
  }
52
- export class OsvScanner {
52
+ /** OSV.dev over HTTP: querybatch for membership, per-id hydration for records. */
53
+ export class OsvHttpSource {
53
54
  cache;
54
55
  rateLimiter;
56
+ network = true;
55
57
  constructor(cache, rateLimiter) {
56
58
  this.cache = cache;
57
59
  this.rateLimiter = rateLimiter;
58
60
  }
59
- async scan(deps, projectPath) {
60
- const start = Date.now();
61
- const scannable = deps.filter((d) => d.version !== null);
62
- const ecosystems = [...new Set(scannable.map((d) => d.ecosystem))];
63
- // Check cache for each dep individually
64
- const uncached = [];
65
- const cachedVulns = [];
66
- for (const dep of scannable) {
67
- const cacheKey = `osv:${dep.ecosystem}:${dep.name}:${dep.version}`;
68
- const cached = this.cache.get(cacheKey);
69
- if (cached !== null) {
70
- cachedVulns.push(...cached.map((v) => restampDepContext(v, dep)));
71
- }
72
- else {
73
- uncached.push(dep);
74
- }
75
- }
76
- // Fetch uncached from OSV
77
- let fetchedVulns = [];
78
- let offline = false;
79
- if (uncached.length > 0) {
80
- if (!this.rateLimiter.canProceed("osv")) {
81
- offline = true;
82
- }
83
- else {
84
- try {
85
- fetchedVulns = await this.batchQuery(uncached);
86
- }
87
- catch {
88
- offline = true;
89
- // Try stale cache for uncached deps
90
- for (const dep of uncached) {
91
- const cacheKey = `osv:${dep.ecosystem}:${dep.name}:${dep.version}`;
92
- const stale = this.cache.getStale(cacheKey);
93
- if (stale)
94
- cachedVulns.push(...stale.data.map((v) => restampDepContext(v, dep)));
95
- }
96
- }
97
- }
98
- }
99
- const allVulns = [...cachedVulns, ...fetchedVulns];
100
- const vulnerablePackages = new Set(allVulns.map((v) => v.package));
101
- const platformInactiveVulnerable = new Set(allVulns.filter((v) => !v.platformActive).map((v) => v.package)).size;
102
- const bySeverity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
103
- for (const v of allVulns) {
104
- bySeverity[v.severity]++;
105
- }
106
- return {
107
- scannedAt: new Date().toISOString(),
108
- projectPath,
109
- ecosystemsScanned: ecosystems,
110
- totalScanned: scannable.length,
111
- totalVulnerable: vulnerablePackages.size,
112
- platformInactiveVulnerable,
113
- bySeverity,
114
- vulnerabilities: allVulns,
115
- cleanCount: offline ? 0 : scannable.length - vulnerablePackages.size,
116
- scanDurationMs: Date.now() - start,
117
- cached: uncached.length === 0,
118
- offline,
119
- };
120
- }
121
- async batchQuery(deps) {
122
- // Phase 1 — enumerate advisory IDs per dep via the lightweight batch index.
61
+ async query(deps) {
123
62
  // querybatch returns only `{ id, modified }`; the rich fields (severity,
124
- // fixed version, summary, references) come from per-advisory hydration below.
63
+ // fixed version, summary, references) come from per-advisory hydration.
125
64
  const chunks = [];
126
65
  for (let offset = 0; offset < deps.length; offset += MAX_BATCH_SIZE)
127
66
  chunks.push(deps.slice(offset, offset + MAX_BATCH_SIZE));
@@ -147,27 +86,9 @@ export class OsvScanner {
147
86
  throw new Error(`OSV API error: ${response.status}`);
148
87
  }
149
88
  const data = (await response.json());
150
- return chunk.map((dep, i) => ({ dep, ids: (data.results[i]?.vulns ?? []).map((v) => v.id) }));
89
+ return chunk.map((_, i) => (data.results[i]?.vulns ?? []).map((v) => v.id));
151
90
  });
152
- const matches = answered.flat();
153
- // Phase 2 — hydrate each unique advisory once (cached, bounded concurrency).
154
- const uniqueIds = [...new Set(matches.flatMap((m) => m.ids))];
155
- const detailById = await this.hydrateDetails(uniqueIds);
156
- // Phase 3 — map to entries and cache per-dep (empty results cached too, so
157
- // clean deps are not re-queried).
158
- const results = [];
159
- const rows = [];
160
- for (const { dep, ids } of matches) {
161
- // Drop advisories OSV has withdrawn/retracted — surfacing them is a false
162
- // positive. Un-hydrated ids (detail fetch failed) are kept: we can't know,
163
- // and dropping a real advisory is worse than keeping an un-enriched one.
164
- const liveIds = ids.filter((id) => !detailById.get(id)?.withdrawn);
165
- const depVulns = liveIds.map((id) => mapVulnerability(detailById.get(id) ?? { id }, dep));
166
- rows.push([`osv:${dep.ecosystem}:${dep.name}:${dep.version}`, depVulns]);
167
- results.push(...depVulns);
168
- }
169
- this.cache.setMany(rows, "osv", OSV_CACHE_TTL);
170
- return results;
91
+ return answered.flat();
171
92
  }
172
93
  /**
173
94
  * Hydrate advisory detail for the given IDs. Cache-first (24h), then fetch the
@@ -175,7 +96,7 @@ export class OsvScanner {
175
96
  * skipped (not fatal) — the caller falls back to the index-only shape so a
176
97
  * real advisory is never silently dropped just because its detail 404'd.
177
98
  */
178
- async hydrateDetails(ids) {
99
+ async details(ids) {
179
100
  const out = new Map();
180
101
  const toFetch = [];
181
102
  for (const id of ids) {
@@ -221,6 +142,107 @@ export class OsvScanner {
221
142
  return null;
222
143
  }
223
144
  }
145
+ export class OsvScanner {
146
+ cache;
147
+ rateLimiter;
148
+ source;
149
+ constructor(cache, rateLimiter, source) {
150
+ this.cache = cache;
151
+ this.rateLimiter = rateLimiter;
152
+ this.source = source ?? new OsvHttpSource(cache, rateLimiter);
153
+ }
154
+ async scan(deps, projectPath) {
155
+ const start = Date.now();
156
+ const scannable = deps.filter((d) => d.version !== null);
157
+ const ecosystems = [...new Set(scannable.map((d) => d.ecosystem))];
158
+ // Per-dependency caching and the rate limit are for network answers; a
159
+ // pinned source answers locally and must never seed the shared cache.
160
+ const network = this.source.network;
161
+ // Check cache for each dep individually
162
+ const uncached = [];
163
+ const cachedVulns = [];
164
+ for (const dep of scannable) {
165
+ const cacheKey = `osv:${dep.ecosystem}:${dep.name}:${dep.version}`;
166
+ const cached = network ? this.cache.get(cacheKey) : null;
167
+ if (cached !== null) {
168
+ cachedVulns.push(...cached.map((v) => restampDepContext(v, dep)));
169
+ }
170
+ else {
171
+ uncached.push(dep);
172
+ }
173
+ }
174
+ // Fetch uncached from OSV
175
+ let fetchedVulns = [];
176
+ let offline = false;
177
+ if (uncached.length > 0) {
178
+ if (network && !this.rateLimiter.canProceed("osv")) {
179
+ offline = true;
180
+ }
181
+ else {
182
+ try {
183
+ fetchedVulns = await this.batchQuery(uncached);
184
+ }
185
+ catch {
186
+ offline = true;
187
+ // Try stale cache for uncached deps
188
+ for (const dep of uncached) {
189
+ if (!network)
190
+ break;
191
+ const cacheKey = `osv:${dep.ecosystem}:${dep.name}:${dep.version}`;
192
+ const stale = this.cache.getStale(cacheKey);
193
+ if (stale)
194
+ cachedVulns.push(...stale.data.map((v) => restampDepContext(v, dep)));
195
+ }
196
+ }
197
+ }
198
+ }
199
+ const allVulns = [...cachedVulns, ...fetchedVulns];
200
+ const vulnerablePackages = new Set(allVulns.map((v) => v.package));
201
+ const platformInactiveVulnerable = new Set(allVulns.filter((v) => !v.platformActive).map((v) => v.package)).size;
202
+ const bySeverity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 };
203
+ for (const v of allVulns) {
204
+ bySeverity[v.severity]++;
205
+ }
206
+ return {
207
+ scannedAt: new Date().toISOString(),
208
+ projectPath,
209
+ ecosystemsScanned: ecosystems,
210
+ totalScanned: scannable.length,
211
+ totalVulnerable: vulnerablePackages.size,
212
+ platformInactiveVulnerable,
213
+ bySeverity,
214
+ vulnerabilities: allVulns,
215
+ cleanCount: offline ? 0 : scannable.length - vulnerablePackages.size,
216
+ scanDurationMs: Date.now() - start,
217
+ cached: uncached.length === 0,
218
+ offline,
219
+ };
220
+ }
221
+ async batchQuery(deps) {
222
+ // Phase 1 — enumerate advisory IDs per dep (OSV: the lightweight batch index).
223
+ const idsPerDep = await this.source.query(deps);
224
+ const matches = deps.map((dep, i) => ({ dep, ids: idsPerDep[i] ?? [] }));
225
+ // Phase 2 — the record behind each unique advisory, once.
226
+ const uniqueIds = [...new Set(matches.flatMap((m) => m.ids))];
227
+ const detailById = await this.source.details(uniqueIds);
228
+ // Phase 3 — map to entries and cache per-dep (empty results cached too, so
229
+ // clean deps are not re-queried; network answers only).
230
+ const results = [];
231
+ const rows = [];
232
+ for (const { dep, ids } of matches) {
233
+ // Drop advisories OSV has withdrawn/retracted — surfacing them is a false
234
+ // positive. Un-hydrated ids (detail fetch failed) are kept: we can't know,
235
+ // and dropping a real advisory is worse than keeping an un-enriched one.
236
+ const liveIds = ids.filter((id) => !detailById.get(id)?.withdrawn);
237
+ const depVulns = liveIds.map((id) => mapVulnerability(detailById.get(id) ?? { id }, dep));
238
+ rows.push([`osv:${dep.ecosystem}:${dep.name}:${dep.version}`, depVulns]);
239
+ results.push(...depVulns);
240
+ }
241
+ if (this.source.network)
242
+ this.cache.setMany(rows, "osv", OSV_CACHE_TTL);
243
+ return results;
244
+ }
245
+ }
224
246
  /**
225
247
  * Overwrite the dep-context fields of a cached advisory entry with the CURRENT
226
248
  * resolution's context. The advisory facts (id, severity, summary, fix version)
@@ -0,0 +1,31 @@
1
+ /**
2
+ * An advisory source that serves a fixed set of OSV records and decides
3
+ * membership itself, with no network.
4
+ *
5
+ * The server asks OSV.dev which advisories affect a version (OsvHttpSource,
6
+ * osv-scanner.ts). The 4DA conformance corpus pins every OSV record that names
7
+ * one of its packages, byte for byte as api.osv.dev served it, and its truth
8
+ * is OSV's own evaluation of each installed version. Scoring the engine
9
+ * against that corpus offline needs the same question answered locally: which
10
+ * pinned records affect this exact (ecosystem, name, version)? That is the
11
+ * job of osv-ranges.ts (spans and explicit version lists, ordered the
12
+ * ecosystem's way), so this also holds the local evaluator to OSV's answers.
13
+ *
14
+ * Withdrawn records are still returned by `query`: the scanner drops them
15
+ * after reading the record, exactly as it does with OSV's answers.
16
+ */
17
+ import type { AdvisorySource } from "./osv-scanner.js";
18
+ import type { OsvVulnerability, ResolvedDependency } from "./types.js";
19
+ export declare class PinnedRecordsSource implements AdvisorySource {
20
+ readonly network = false;
21
+ private readonly byId;
22
+ private readonly byPackage;
23
+ constructor(records: Iterable<OsvVulnerability>);
24
+ /** Every `*.json` OSV record in a directory (the corpus's `osv/`). */
25
+ static fromDirectory(dir: string): PinnedRecordsSource;
26
+ get size(): number;
27
+ /** The ids of the pinned records that affect this exact version (withdrawn ones included). */
28
+ affecting(ecosystem: string, name: string, version: string): string[];
29
+ query(deps: ResolvedDependency[]): Promise<string[][]>;
30
+ details(ids: string[]): Promise<Map<string, OsvVulnerability>>;
31
+ }
@@ -0,0 +1,80 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * An advisory source that serves a fixed set of OSV records and decides
4
+ * membership itself, with no network.
5
+ *
6
+ * The server asks OSV.dev which advisories affect a version (OsvHttpSource,
7
+ * osv-scanner.ts). The 4DA conformance corpus pins every OSV record that names
8
+ * one of its packages, byte for byte as api.osv.dev served it, and its truth
9
+ * is OSV's own evaluation of each installed version. Scoring the engine
10
+ * against that corpus offline needs the same question answered locally: which
11
+ * pinned records affect this exact (ecosystem, name, version)? That is the
12
+ * job of osv-ranges.ts (spans and explicit version lists, ordered the
13
+ * ecosystem's way), so this also holds the local evaluator to OSV's answers.
14
+ *
15
+ * Withdrawn records are still returned by `query`: the scanner drops them
16
+ * after reading the record, exactly as it does with OSV's answers.
17
+ */
18
+ import * as fs from "node:fs";
19
+ import * as path from "node:path";
20
+ import { advisoryAffects, collectAffectedSpans } from "./osv-ranges.js";
21
+ import { samePackageName } from "./version-compare.js";
22
+ /** The registry's identity for a package name, as an index key (samePackageName's rules). */
23
+ function nameKey(ecosystem, name) {
24
+ if (ecosystem === "PyPI")
25
+ return name.toLowerCase().replace(/[-_.]+/g, "-");
26
+ if (ecosystem === "crates.io")
27
+ return name.toLowerCase().replace(/_/g, "-");
28
+ return name;
29
+ }
30
+ /** Explicitly listed versions for one package of one record. */
31
+ function listedVersions(vuln, name, ecosystem) {
32
+ return (vuln.affected ?? [])
33
+ .filter((a) => a.package.ecosystem === ecosystem && samePackageName(a.package.name, name, ecosystem))
34
+ .flatMap((a) => a.versions ?? []);
35
+ }
36
+ export class PinnedRecordsSource {
37
+ network = false;
38
+ byId = new Map();
39
+ byPackage = new Map();
40
+ constructor(records) {
41
+ for (const record of records) {
42
+ this.byId.set(record.id, record);
43
+ const keys = new Set((record.affected ?? []).map((a) => `${a.package.ecosystem}\0${nameKey(a.package.ecosystem, a.package.name)}`));
44
+ for (const key of keys) {
45
+ const list = this.byPackage.get(key);
46
+ if (list)
47
+ list.push(record);
48
+ else
49
+ this.byPackage.set(key, [record]);
50
+ }
51
+ }
52
+ }
53
+ /** Every `*.json` OSV record in a directory (the corpus's `osv/`). */
54
+ static fromDirectory(dir) {
55
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")).sort();
56
+ return new PinnedRecordsSource(files.map((f) => JSON.parse(fs.readFileSync(path.join(dir, f), "utf8"))));
57
+ }
58
+ get size() {
59
+ return this.byId.size;
60
+ }
61
+ /** The ids of the pinned records that affect this exact version (withdrawn ones included). */
62
+ affecting(ecosystem, name, version) {
63
+ const candidates = this.byPackage.get(`${ecosystem}\0${nameKey(ecosystem, name)}`) ?? [];
64
+ return candidates
65
+ .filter((vuln) => advisoryAffects({ id: vuln.id, spans: collectAffectedSpans(vuln.affected, name, ecosystem), versions: listedVersions(vuln, name, ecosystem) }, version, ecosystem))
66
+ .map((vuln) => vuln.id);
67
+ }
68
+ async query(deps) {
69
+ return deps.map((dep) => (dep.version === null ? [] : this.affecting(dep.ecosystem, dep.name, dep.version)));
70
+ }
71
+ async details(ids) {
72
+ const out = new Map();
73
+ for (const id of ids) {
74
+ const record = this.byId.get(id);
75
+ if (record)
76
+ out.set(id, record);
77
+ }
78
+ return out;
79
+ }
80
+ }
@@ -27,6 +27,8 @@ export declare const DATABASE_FREE_TOOLS: ReadonlySet<string>;
27
27
  * context; the host already owns the process).
28
28
  */
29
29
  export declare function dispatchTool(name: string, db: FourDADatabase | null, args: Record<string, unknown> | undefined): Promise<CallToolResult>;
30
+ /** The sentence `sources_note` carries, or null when no source is turned off. */
31
+ export declare function sourcesNote(disabled: string[]): string | null;
30
32
  /** What a desktop-only tool says when no desktop app database was found. */
31
33
  export declare function desktopAppNote(name: string): string;
32
34
  /** Check if a tool exists in the dispatch map */
@@ -106,7 +106,8 @@ export async function dispatchTool(name, db, args) {
106
106
  // hides these in standalone mode, but a host can call one by name) used to
107
107
  // answer `[]`, which reads as "nothing relevant" rather than "no app".
108
108
  const desktopOnly = db !== null && db.isStandalone && TOOL_REGISTRY[name]?.standalone === false;
109
- const payload = cleanStrings(withFreshness ? attachFreshness(db, result) : desktopOnly ? attachDesktopAppNote(name, result) : result);
109
+ const annotated = withFreshness ? attachFreshness(db, result) : desktopOnly ? attachDesktopAppNote(name, result) : result;
110
+ const payload = cleanStrings(db !== null && !db.isStandalone && SOURCE_FILTERED_TOOLS.has(name) ? attachSourcesNote(db, annotated) : annotated);
110
111
  // An executor that returns `{ error: "<message>" }` failed: say so in the
111
112
  // protocol, not only in the body, so the host and model treat it as an error.
112
113
  const failed = payload !== null && typeof payload === "object" && typeof payload.error === "string";
@@ -160,6 +161,45 @@ function attachFreshness(db, result) {
160
161
  }
161
162
  return { data_freshness, ...extras, result };
162
163
  }
164
+ /**
165
+ * Tools that read the app's feed items and therefore skip items from sources
166
+ * the user turned off in the app (`FourDADatabase.enabledSourceClause`).
167
+ * developer_dna is not here: like the app's own developer_dna.rs it reports
168
+ * reading history, unfiltered, and returns no items.
169
+ */
170
+ const SOURCE_FILTERED_TOOLS = new Set([
171
+ "get_relevant_content",
172
+ "get_actionable_signals",
173
+ "knowledge_gaps",
174
+ "what_should_i_know",
175
+ ]);
176
+ /** The desktop app's always-on source types (`source_class::STACK_SOURCES`); every other type is an opt-in interest. */
177
+ const STACK_SOURCES = new Set(["crates_io", "npm_registry", "pypi", "go_modules", "osv", "cve"]);
178
+ /** The sentence `sources_note` carries, or null when no source is turned off. */
179
+ export function sourcesNote(disabled) {
180
+ if (disabled.length === 0)
181
+ return null;
182
+ const kind = disabled.every((s) => !STACK_SOURCES.has(s)) ? "interest " : "";
183
+ const subject = disabled.length === 1 ? `1 ${kind}source is` : `${disabled.length} ${kind}sources are`;
184
+ return `${subject} turned off in the 4DA app; their items are not included. Turn them on in Settings → Sources.`;
185
+ }
186
+ /** Say, on the payload, that items from turned-off sources were left out. Best-effort, like attachFreshness. */
187
+ function attachSourcesNote(db, result) {
188
+ let sources_note;
189
+ try {
190
+ sources_note = sourcesNote(db.disabledSources());
191
+ }
192
+ catch {
193
+ return result;
194
+ }
195
+ if (!sources_note)
196
+ return result;
197
+ if (Array.isArray(result))
198
+ return { sources_note, item_count: result.length, items: result };
199
+ if (result && typeof result === "object")
200
+ return { sources_note, ...result };
201
+ return { sources_note, result };
202
+ }
163
203
  /** What a desktop-only tool says when no desktop app database was found. */
164
204
  export function desktopAppNote(name) {
165
205
  return (`${name} reads the 4DA desktop app's database, and none was found (standalone mode), so this result is empty or partial. ` +
@@ -33,7 +33,7 @@ export function storedAdvisoriesFor(db, packages, limit = 5) {
33
33
  WHERE source_type IN (${ADVISORY_SOURCES.map(() => "?").join(", ")})
34
34
  AND created_at > datetime('now', '-90 days')
35
35
  AND LOWER(title) LIKE ?
36
- ${hasFeedRelevant ? "AND (feed_relevant IS NULL OR feed_relevant = 1)" : ""}
36
+ ${hasFeedRelevant ? "AND (feed_relevant IS NULL OR feed_relevant = 1)" : ""}${db.enabledSourceClause("source_type")}
37
37
  ORDER BY created_at DESC LIMIT 20`)
38
38
  .all(...ADVISORY_SOURCES, `%${pkg.name.toLowerCase()}%`);
39
39
  }
@@ -226,7 +226,7 @@ export function releasesFor(db, pkg) {
226
226
  rows = db
227
227
  .getRawDb()
228
228
  .prepare(`SELECT title, url, created_at FROM source_items
229
- WHERE source_type = ? AND title LIKE ? ORDER BY created_at DESC LIMIT 50`)
229
+ WHERE source_type = ? AND title LIKE ?${db.enabledSourceClause("source_type")} ORDER BY created_at DESC LIMIT 50`)
230
230
  .all(spec.source, `${spec.prefix}${pkg.name} v%`);
231
231
  }
232
232
  catch {
@@ -266,7 +266,7 @@ export function judgedReadingFor(db, packages, limit = 5) {
266
266
  SELECT s.id, s.title, s.url, s.source_type, l.relevance_score AS verdict, s.created_at
267
267
  FROM source_items s JOIN latest l ON l.source_item_id = s.id AND l.rn = 1
268
268
  WHERE s.feed_relevant = 1 AND l.relevance_score >= 0.5
269
- AND s.created_at > datetime('now', '-30 days')
269
+ AND s.created_at > datetime('now', '-30 days')${db.enabledSourceClause("s.source_type")}
270
270
  ORDER BY l.relevance_score DESC, s.created_at DESC LIMIT 2000`)
271
271
  .all();
272
272
  }
@@ -56,7 +56,7 @@ export function loadCandidates(db) {
56
56
  ${has("source_id") ? "si.source_id" : "NULL AS source_id"},
57
57
  substr(COALESCE(si.content, ''), 1, 2000) AS content_head
58
58
  FROM source_items si
59
- WHERE si.created_at >= datetime('now', '-30 days')
59
+ WHERE si.created_at >= datetime('now', '-30 days')${db.enabledSourceClause("si.source_type")}
60
60
  ${hasRelevance ? "AND si.relevance_score IS NOT NULL AND si.relevance_score >= 0.2" : ""}
61
61
  ${hasContentType ? `AND (si.content_type IS NULL OR si.content_type NOT IN (${excludedTypes}))` : ""}
62
62
  ${engaged}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4da/mcp-server",
3
- "version": "6.2.0",
3
+ "version": "6.2.2",
4
4
  "mcpName": "io.github.4DA-Systems/4da-mcp-server",
5
5
  "description": "Upgrade intelligence for AI coding agents: what changes between your installed and target version (changelog, breaking changes, the files you import it from), a pre-install verdict on each bump, vulnerability scanning at osv-scanner parity across npm/pnpm/yarn, Cargo, Python and Go lockfiles, ranked upgrade plans, task-scoped briefings, decision memory. Local, no account; works standalone, and adds the 4DA desktop app's signals, knowledge gaps and stack-judged news when it is installed.",
6
6
  "type": "module",