@4da/mcp-server 5.0.4 → 6.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/CHANGELOG.md +698 -0
  2. package/README.md +70 -39
  3. package/dist/build-staleness.d.ts +35 -0
  4. package/dist/build-staleness.js +95 -0
  5. package/dist/db.d.ts +53 -58
  6. package/dist/db.js +226 -299
  7. package/dist/doctor.js +27 -7
  8. package/dist/embeddings.js +7 -1
  9. package/dist/freshness-markers.d.ts +34 -0
  10. package/dist/freshness-markers.js +74 -0
  11. package/dist/gitignore.d.ts +37 -0
  12. package/dist/gitignore.js +130 -0
  13. package/dist/index.js +127 -44
  14. package/dist/live/bun-lockfile.d.ts +26 -0
  15. package/dist/live/bun-lockfile.js +123 -0
  16. package/dist/live/cargo-platform.d.ts +38 -0
  17. package/dist/live/cargo-platform.js +178 -0
  18. package/dist/live/changelog-classify.d.ts +41 -0
  19. package/dist/live/changelog-classify.js +207 -0
  20. package/dist/live/changelog.d.ts +64 -0
  21. package/dist/live/changelog.js +318 -0
  22. package/dist/live/crates-registry.js +3 -1
  23. package/dist/live/crates-versions.d.ts +47 -0
  24. package/dist/live/crates-versions.js +201 -0
  25. package/dist/live/dependency-set.d.ts +13 -0
  26. package/dist/live/dependency-set.js +95 -0
  27. package/dist/live/dev-scope.d.ts +32 -0
  28. package/dist/live/dev-scope.js +102 -0
  29. package/dist/live/file-signature.d.ts +18 -0
  30. package/dist/live/file-signature.js +43 -0
  31. package/dist/live/go-registry.js +3 -1
  32. package/dist/live/hn-fetcher.js +9 -3
  33. package/dist/live/index.d.ts +105 -2
  34. package/dist/live/index.js +266 -139
  35. package/dist/live/install-state.d.ts +43 -0
  36. package/dist/live/install-state.js +150 -0
  37. package/dist/live/js-lockfile-readers.d.ts +46 -0
  38. package/dist/live/js-lockfile-readers.js +181 -0
  39. package/dist/live/lockfile-parsers-pygo.d.ts +60 -0
  40. package/dist/live/lockfile-parsers-pygo.js +274 -0
  41. package/dist/live/lockfile-parsers.d.ts +69 -0
  42. package/dist/live/lockfile-parsers.js +311 -0
  43. package/dist/live/lockfile-types.d.ts +34 -0
  44. package/dist/live/lockfile-types.js +42 -0
  45. package/dist/live/maintenance.d.ts +33 -0
  46. package/dist/live/maintenance.js +37 -0
  47. package/dist/live/npm-packument.d.ts +47 -0
  48. package/dist/live/npm-packument.js +150 -0
  49. package/dist/live/npm-registry.js +67 -53
  50. package/dist/live/osv-scanner.d.ts +12 -1
  51. package/dist/live/osv-scanner.js +50 -15
  52. package/dist/live/package-archive.d.ts +49 -0
  53. package/dist/live/package-archive.js +186 -0
  54. package/dist/live/pypi-registry.js +3 -1
  55. package/dist/live/registry-health.d.ts +16 -0
  56. package/dist/live/registry-health.js +74 -0
  57. package/dist/live/release-metadata.d.ts +67 -0
  58. package/dist/live/release-metadata.js +16 -0
  59. package/dist/live/resolution.d.ts +43 -0
  60. package/dist/live/resolution.js +62 -0
  61. package/dist/live/semver-precedence.d.ts +30 -0
  62. package/dist/live/semver-precedence.js +83 -0
  63. package/dist/live/severity-scope.d.ts +57 -0
  64. package/dist/live/severity-scope.js +89 -0
  65. package/dist/live/types.d.ts +83 -0
  66. package/dist/live/upgrade-sources.d.ts +71 -0
  67. package/dist/live/upgrade-sources.js +198 -0
  68. package/dist/live/version-compare.d.ts +39 -0
  69. package/dist/live/version-compare.js +130 -0
  70. package/dist/live/version-resolver.d.ts +14 -1
  71. package/dist/live/version-resolver.js +93 -303
  72. package/dist/native-bindings.d.ts +19 -0
  73. package/dist/native-bindings.js +39 -0
  74. package/dist/project-manifests.d.ts +40 -0
  75. package/dist/project-manifests.js +193 -0
  76. package/dist/project-scanner.js +38 -48
  77. package/dist/project-tree.d.ts +41 -0
  78. package/dist/project-tree.js +110 -0
  79. package/dist/prompts.d.ts +28 -0
  80. package/dist/prompts.js +54 -0
  81. package/dist/schema-registry.d.ts +41 -29
  82. package/dist/schema-registry.js +98 -105
  83. package/dist/server-instructions.d.ts +11 -0
  84. package/dist/server-instructions.js +36 -0
  85. package/dist/tool-args.d.ts +28 -0
  86. package/dist/tool-args.js +93 -0
  87. package/dist/tool-dispatch.d.ts +1 -1
  88. package/dist/tool-dispatch.js +42 -8
  89. package/dist/tools/app-plan.d.ts +92 -0
  90. package/dist/tools/app-plan.js +155 -0
  91. package/dist/tools/briefing-stored-advisories.d.ts +20 -0
  92. package/dist/tools/briefing-stored-advisories.js +58 -0
  93. package/dist/tools/briefing-task-scope.d.ts +73 -0
  94. package/dist/tools/briefing-task-scope.js +197 -0
  95. package/dist/tools/briefing-wisdom.d.ts +31 -0
  96. package/dist/tools/briefing-wisdom.js +148 -0
  97. package/dist/tools/decision-enforcement.d.ts +0 -5
  98. package/dist/tools/decision-enforcement.js +0 -4
  99. package/dist/tools/decision-memory.js +18 -66
  100. package/dist/tools/dependency-check-signals.d.ts +74 -0
  101. package/dist/tools/dependency-check-signals.js +264 -0
  102. package/dist/tools/dependency-check.d.ts +98 -0
  103. package/dist/tools/dependency-check.js +295 -0
  104. package/dist/tools/dependency-health.js +74 -21
  105. package/dist/tools/developer-dna.d.ts +3 -0
  106. package/dist/tools/developer-dna.js +16 -15
  107. package/dist/tools/ecosystem-pulse.d.ts +6 -2
  108. package/dist/tools/ecosystem-pulse.js +53 -14
  109. package/dist/tools/get-actionable-signals.d.ts +26 -4
  110. package/dist/tools/get-actionable-signals.js +214 -251
  111. package/dist/tools/get-context.d.ts +2 -0
  112. package/dist/tools/get-context.js +9 -1
  113. package/dist/tools/get-relevant-content.d.ts +3 -1
  114. package/dist/tools/get-relevant-content.js +34 -3
  115. package/dist/tools/index.d.ts +4 -2
  116. package/dist/tools/index.js +4 -2
  117. package/dist/tools/install-drift-notes.d.ts +51 -0
  118. package/dist/tools/install-drift-notes.js +172 -0
  119. package/dist/tools/knowledge-gap-citations.d.ts +55 -0
  120. package/dist/tools/knowledge-gap-citations.js +132 -0
  121. package/dist/tools/knowledge-gap-exposure.d.ts +72 -0
  122. package/dist/tools/knowledge-gap-exposure.js +160 -0
  123. package/dist/tools/knowledge-gap-grading.d.ts +91 -0
  124. package/dist/tools/knowledge-gap-grading.js +157 -0
  125. package/dist/tools/knowledge-gap-installs.d.ts +65 -0
  126. package/dist/tools/knowledge-gap-installs.js +134 -0
  127. package/dist/tools/knowledge-gap-ranges.d.ts +51 -0
  128. package/dist/tools/knowledge-gap-ranges.js +146 -0
  129. package/dist/tools/knowledge-gap-scope.d.ts +63 -0
  130. package/dist/tools/knowledge-gap-scope.js +118 -0
  131. package/dist/tools/knowledge-gaps.d.ts +12 -3
  132. package/dist/tools/knowledge-gaps.js +172 -121
  133. package/dist/tools/package-presence.d.ts +24 -0
  134. package/dist/tools/package-presence.js +35 -0
  135. package/dist/tools/record-feedback.d.ts +0 -1
  136. package/dist/tools/record-feedback.js +7 -8
  137. package/dist/tools/signal-classifier.d.ts +25 -0
  138. package/dist/tools/signal-classifier.js +40 -0
  139. package/dist/tools/upgrade-impact-callsites.d.ts +52 -0
  140. package/dist/tools/upgrade-impact-callsites.js +226 -0
  141. package/dist/tools/upgrade-impact-literals.d.ts +45 -0
  142. package/dist/tools/upgrade-impact-literals.js +118 -0
  143. package/dist/tools/upgrade-impact-report.d.ts +80 -0
  144. package/dist/tools/upgrade-impact-report.js +178 -0
  145. package/dist/tools/upgrade-impact.d.ts +84 -0
  146. package/dist/tools/upgrade-impact.js +305 -0
  147. package/dist/tools/upgrade-planner.d.ts +41 -8
  148. package/dist/tools/upgrade-planner.js +201 -53
  149. package/dist/tools/vulnerability-scan-concise.d.ts +72 -0
  150. package/dist/tools/vulnerability-scan-concise.js +97 -0
  151. package/dist/tools/vulnerability-scan-format.d.ts +137 -0
  152. package/dist/tools/vulnerability-scan-format.js +276 -0
  153. package/dist/tools/vulnerability-scan.d.ts +338 -18
  154. package/dist/tools/vulnerability-scan.js +84 -125
  155. package/dist/tools/what-should-i-know.d.ts +86 -19
  156. package/dist/tools/what-should-i-know.js +277 -283
  157. package/dist/types.d.ts +4 -136
  158. package/package.json +35 -35
  159. package/dist/schemas/agent-memory.json +0 -49
  160. package/dist/schemas/decision-enforcement.json +0 -22
  161. package/dist/schemas/decision-memory.json +0 -54
  162. package/dist/schemas/dependency-health.json +0 -27
  163. package/dist/schemas/developer-dna.json +0 -219
  164. package/dist/schemas/ecosystem-pulse.json +0 -17
  165. package/dist/schemas/get-actionable-signals.json +0 -29
  166. package/dist/schemas/get-context.json +0 -17
  167. package/dist/schemas/get-relevant-content.json +0 -26
  168. package/dist/schemas/knowledge-gaps.json +0 -14
  169. package/dist/schemas/record-feedback.json +0 -24
  170. package/dist/schemas/upgrade-planner.json +0 -22
  171. package/dist/schemas/vulnerability-scan.json +0 -26
  172. package/dist/schemas/what-should-i-know.json +0 -19
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The shape every lockfile reader returns, shared by lockfile-parsers.ts,
3
+ * lockfile-parsers-pygo.ts and js-lockfile-readers.ts.
4
+ */
5
+ /** One installed copy of a package. `dev` is undefined when the lockfile does not say. */
6
+ export interface PackageInstance {
7
+ name: string;
8
+ version: string;
9
+ dev?: boolean;
10
+ }
11
+ /** Versions read from one directory, and the file they came from. */
12
+ export interface VersionSource {
13
+ /** The version a direct dependency of this name resolves to. */
14
+ versions: Map<string, string>;
15
+ /** Every installed copy, deduplicated by name@version. */
16
+ instances: PackageInstance[];
17
+ /** Absolute path of the file the versions were read from, or null when none exists. */
18
+ source: string | null;
19
+ /** `lockfile` = exact versions; `manifest` = read from a manifest (pins or specifier floors). */
20
+ kind: "lockfile" | "manifest" | null;
21
+ }
22
+ export declare function emptySource(): VersionSource;
23
+ /**
24
+ * Collects instances deduplicated by name@version. A copy seen as runtime
25
+ * anywhere stays runtime; dev is only claimed when every sighting says dev,
26
+ * and unknown when any sighting does not say.
27
+ */
28
+ export declare class InstanceSet {
29
+ private byKey;
30
+ add(name: string, version: string, dev?: boolean): void;
31
+ get size(): number;
32
+ toArray(): PackageInstance[];
33
+ }
34
+ export declare function found(versions: Map<string, string>, instances: InstanceSet | PackageInstance[], source: string, kind: "lockfile" | "manifest"): VersionSource;
@@ -0,0 +1,42 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * The shape every lockfile reader returns, shared by lockfile-parsers.ts,
4
+ * lockfile-parsers-pygo.ts and js-lockfile-readers.ts.
5
+ */
6
+ export function emptySource() {
7
+ return { versions: new Map(), instances: [], source: null, kind: null };
8
+ }
9
+ /**
10
+ * Collects instances deduplicated by name@version. A copy seen as runtime
11
+ * anywhere stays runtime; dev is only claimed when every sighting says dev,
12
+ * and unknown when any sighting does not say.
13
+ */
14
+ export class InstanceSet {
15
+ byKey = new Map();
16
+ add(name, version, dev) {
17
+ const key = `${name}\0${version}`;
18
+ const existing = this.byKey.get(key);
19
+ if (!existing) {
20
+ this.byKey.set(key, dev === undefined ? { name, version } : { name, version, dev });
21
+ return;
22
+ }
23
+ if (existing.dev === undefined || dev === undefined)
24
+ delete existing.dev;
25
+ else
26
+ existing.dev = existing.dev && dev;
27
+ }
28
+ get size() {
29
+ return this.byKey.size;
30
+ }
31
+ toArray() {
32
+ return [...this.byKey.values()];
33
+ }
34
+ }
35
+ export function found(versions, instances, source, kind) {
36
+ return {
37
+ versions,
38
+ instances: Array.isArray(instances) ? instances : instances.toArray(),
39
+ source,
40
+ kind,
41
+ };
42
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Maintenance-notice predicate, shared by every consumer of a vulnerability
3
+ * scan (vulnerability_scan, get_actionable_signals, what_should_i_know,
4
+ * dependency_health) so they all draw the same line.
5
+ */
6
+ import type { VulnerabilityEntry } from "./types.js";
7
+ /**
8
+ * Is this advisory a maintenance notice rather than a vulnerability?
9
+ *
10
+ * RustSec publishes "X is unmaintained" as an advisory so tooling can see it,
11
+ * and OSV carries it through with no severity and no fixed version. It is real
12
+ * information — an unmaintained dependency is a risk to plan around — but it is
13
+ * not something an attacker can exploit and not something you can upgrade away
14
+ * today, so it does not belong in the same list, the same count, or the same
15
+ * "Review X" recommendation as a live CVE.
16
+ *
17
+ * Live scan: 23 of 41 findings were these (the whole gtk-rs GTK3 family, all
18
+ * five `unic-*` crates, `paste`, `proc-macro-error`, `ttf-parser`), producing
19
+ * 27 "Review" lines around six genuinely actionable upgrades.
20
+ *
21
+ * Matched on the advisory's own wording, which RustSec keeps consistent, and
22
+ * deliberately NOT on "deprecated" — that word appears in plenty of real
23
+ * vulnerability summaries.
24
+ */
25
+ export declare function isMaintenanceNotice(v: Pick<VulnerabilityEntry, "summary">): boolean;
26
+ /**
27
+ * The advisories a scan can actually ask the user to act on today: built on
28
+ * this host AND exploitable (not a maintenance notice). This is the set
29
+ * `vulnerability_scan` reports as `vulnerabilities` / `total_vulnerable_packages`;
30
+ * every other tool that counts "vulnerable" must count the same set or the
31
+ * numbers disagree across tools over one scan.
32
+ */
33
+ export declare function isActionableVulnerability(v: Pick<VulnerabilityEntry, "summary" | "platformActive">): boolean;
@@ -0,0 +1,37 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Maintenance-notice predicate, shared by every consumer of a vulnerability
4
+ * scan (vulnerability_scan, get_actionable_signals, what_should_i_know,
5
+ * dependency_health) so they all draw the same line.
6
+ */
7
+ /**
8
+ * Is this advisory a maintenance notice rather than a vulnerability?
9
+ *
10
+ * RustSec publishes "X is unmaintained" as an advisory so tooling can see it,
11
+ * and OSV carries it through with no severity and no fixed version. It is real
12
+ * information — an unmaintained dependency is a risk to plan around — but it is
13
+ * not something an attacker can exploit and not something you can upgrade away
14
+ * today, so it does not belong in the same list, the same count, or the same
15
+ * "Review X" recommendation as a live CVE.
16
+ *
17
+ * Live scan: 23 of 41 findings were these (the whole gtk-rs GTK3 family, all
18
+ * five `unic-*` crates, `paste`, `proc-macro-error`, `ttf-parser`), producing
19
+ * 27 "Review" lines around six genuinely actionable upgrades.
20
+ *
21
+ * Matched on the advisory's own wording, which RustSec keeps consistent, and
22
+ * deliberately NOT on "deprecated" — that word appears in plenty of real
23
+ * vulnerability summaries.
24
+ */
25
+ export function isMaintenanceNotice(v) {
26
+ return /\b(unmaintained|no longer maintained)\b/i.test(v.summary || "");
27
+ }
28
+ /**
29
+ * The advisories a scan can actually ask the user to act on today: built on
30
+ * this host AND exploitable (not a maintenance notice). This is the set
31
+ * `vulnerability_scan` reports as `vulnerabilities` / `total_vulnerable_packages`;
32
+ * every other tool that counts "vulnerable" must count the same set or the
33
+ * numbers disagree across tools over one scan.
34
+ */
35
+ export function isActionableVulnerability(v) {
36
+ return v.platformActive !== false && !isMaintenanceNotice(v);
37
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * npm full-packument reader for dependency_check.
3
+ *
4
+ * The abbreviated metadata the freshness reader uses carries no publish times,
5
+ * publishers, attestations, scripts or per-version dependencies, so this reads
6
+ * the FULL packument (`GET registry.npmjs.org/<name>`) and keeps only what the
7
+ * signals need. Some packuments are ~15 MB, so the projection is cached on
8
+ * disk (the live_cache table) together with the registry's ETag, and later
9
+ * reads revalidate with `If-None-Match` — an unchanged package costs a 304.
10
+ *
11
+ * Privacy: the request names the package and nothing else. The installed
12
+ * version is never sent; versions are picked out of the full list locally.
13
+ */
14
+ import type { LiveCache } from "./cache.js";
15
+ import type { RateLimiter } from "./rate-limiter.js";
16
+ import type { PackageReleaseIndex, RegistryLookup, ReleaseMetadataSource } from "./release-metadata.js";
17
+ interface NpmVersionDoc {
18
+ _npmUser?: {
19
+ name?: string;
20
+ trustedPublisher?: {
21
+ id?: string;
22
+ } | null;
23
+ };
24
+ dist?: {
25
+ attestations?: unknown;
26
+ };
27
+ scripts?: Record<string, string>;
28
+ gypfile?: boolean;
29
+ hasInstallScript?: boolean;
30
+ dependencies?: Record<string, string>;
31
+ optionalDependencies?: Record<string, string>;
32
+ deprecated?: string;
33
+ }
34
+ export interface NpmPackument {
35
+ versions?: Record<string, NpmVersionDoc>;
36
+ time?: Record<string, unknown>;
37
+ }
38
+ export declare class NpmPackumentReader implements ReleaseMetadataSource {
39
+ private cache;
40
+ private rateLimiter;
41
+ constructor(cache: LiveCache, rateLimiter: RateLimiter);
42
+ getReleases(name: string): Promise<RegistryLookup<PackageReleaseIndex>>;
43
+ getCreatedAt(name: string): Promise<RegistryLookup<string | null>>;
44
+ }
45
+ /** Keep only what dependency_check reads. Exported for tests. */
46
+ export declare function projectPackument(name: string, doc: NpmPackument): PackageReleaseIndex;
47
+ export {};
@@ -0,0 +1,150 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * npm full-packument reader for dependency_check.
4
+ *
5
+ * The abbreviated metadata the freshness reader uses carries no publish times,
6
+ * publishers, attestations, scripts or per-version dependencies, so this reads
7
+ * the FULL packument (`GET registry.npmjs.org/<name>`) and keeps only what the
8
+ * signals need. Some packuments are ~15 MB, so the projection is cached on
9
+ * disk (the live_cache table) together with the registry's ETag, and later
10
+ * reads revalidate with `If-None-Match` — an unchanged package costs a 304.
11
+ *
12
+ * Privacy: the request names the package and nothing else. The installed
13
+ * version is never sent; versions are picked out of the full list locally.
14
+ */
15
+ import { fetchWithTimeout } from "./http-utils.js";
16
+ const NPM_REGISTRY_URL = "https://registry.npmjs.org";
17
+ // Full packuments can be large; 8 s (the abbreviated reader's budget) is not enough.
18
+ const PACKUMENT_TIMEOUT_MS = 30_000;
19
+ /** Serve the cached projection without revalidating for this long. */
20
+ const FRESH_MS = 15 * 60 * 1000;
21
+ /** Keep the row (and its ETag) this long so revalidation survives restarts. */
22
+ const ROW_TTL_SECONDS = 7 * 86_400;
23
+ const INSTALL_HOOKS = ["preinstall", "install", "postinstall"];
24
+ export class NpmPackumentReader {
25
+ cache;
26
+ rateLimiter;
27
+ constructor(cache, rateLimiter) {
28
+ this.cache = cache;
29
+ this.rateLimiter = rateLimiter;
30
+ }
31
+ async getReleases(name) {
32
+ const key = `npm-packument:${name}`;
33
+ const cached = this.cache.getStale(key)?.data ?? null;
34
+ if (cached && Date.now() - cached.fetchedAtMs < FRESH_MS) {
35
+ return { status: "ok", data: cached.index };
36
+ }
37
+ if (!this.rateLimiter.consume("npm")) {
38
+ return { status: "unreachable", detail: "npm registry rate limit reached for this minute" };
39
+ }
40
+ const headers = { Accept: "application/json" };
41
+ if (cached?.etag)
42
+ headers["If-None-Match"] = cached.etag;
43
+ let response;
44
+ try {
45
+ response = await fetchWithTimeout(`${NPM_REGISTRY_URL}/${encodeURIComponent(name)}`, { headers }, PACKUMENT_TIMEOUT_MS);
46
+ }
47
+ catch (err) {
48
+ return { status: "unreachable", detail: `npm registry: ${errorText(err)}` };
49
+ }
50
+ if (response.status === 304 && cached) {
51
+ this.cache.set(key, { ...cached, fetchedAtMs: Date.now() }, "npm", ROW_TTL_SECONDS);
52
+ return { status: "ok", data: cached.index };
53
+ }
54
+ if (response.status === 404) {
55
+ return { status: "not_found", detail: `"${name}" is not on the public npm registry` };
56
+ }
57
+ if (!response.ok) {
58
+ return { status: "unreachable", detail: `npm registry answered HTTP ${response.status}` };
59
+ }
60
+ let doc;
61
+ try {
62
+ doc = (await response.json());
63
+ }
64
+ catch (err) {
65
+ return { status: "unreachable", detail: `npm registry: unreadable packument (${errorText(err)})` };
66
+ }
67
+ const index = projectPackument(name, doc);
68
+ this.cache.set(key, { etag: response.headers.get("etag"), fetchedAtMs: Date.now(), index }, "npm", ROW_TTL_SECONDS);
69
+ return { status: "ok", data: index };
70
+ }
71
+ async getCreatedAt(name) {
72
+ const lookup = await this.getReleases(name);
73
+ return lookup.status === "ok" ? { status: "ok", data: lookup.data.createdAt } : lookup;
74
+ }
75
+ }
76
+ /** Keep only what dependency_check reads. Exported for tests. */
77
+ export function projectPackument(name, doc) {
78
+ const time = doc.time ?? {};
79
+ const releases = {};
80
+ for (const [version, v] of Object.entries(doc.versions ?? {})) {
81
+ const { trust, trustEvidence } = npmTrust(v);
82
+ releases[version] = {
83
+ version,
84
+ publishedAt: typeof time[version] === "string" ? time[version] : null,
85
+ trust,
86
+ trustEvidence,
87
+ installScripts: installScriptsOf(v),
88
+ deprecated: typeof v.deprecated === "string" && v.deprecated.length > 0 ? v.deprecated : null,
89
+ withdrawn: null,
90
+ dependencies: [
91
+ ...new Set([...Object.keys(v.dependencies ?? {}), ...Object.keys(v.optionalDependencies ?? {})]),
92
+ ],
93
+ };
94
+ }
95
+ // A version listed in `time` but absent from `versions` was unpublished —
96
+ // npm keeps the timestamp so the number can never be reused. The 2026-03-31
97
+ // axios 1.14.1 / 0.30.4 releases look exactly like this today.
98
+ const unpublished = new Set();
99
+ for (const k of Object.keys(time)) {
100
+ if (k !== "created" && k !== "modified" && k !== "unpublished" && !releases[k])
101
+ unpublished.add(k);
102
+ }
103
+ const whole = time.unpublished;
104
+ if (whole && Array.isArray(whole.versions)) {
105
+ for (const v of whole.versions)
106
+ if (typeof v === "string" && !releases[v])
107
+ unpublished.add(v);
108
+ }
109
+ for (const version of unpublished) {
110
+ releases[version] = {
111
+ version,
112
+ publishedAt: typeof time[version] === "string" ? time[version] : null,
113
+ trust: 0,
114
+ trustEvidence: "unknown (release unpublished; its metadata is gone)",
115
+ installScripts: null,
116
+ deprecated: null,
117
+ withdrawn: "unpublished",
118
+ dependencies: null,
119
+ };
120
+ }
121
+ return {
122
+ ecosystem: "npm",
123
+ name,
124
+ createdAt: typeof time.created === "string" ? time.created : null,
125
+ releases,
126
+ };
127
+ }
128
+ function npmTrust(v) {
129
+ const tp = v._npmUser?.trustedPublisher;
130
+ if (tp)
131
+ return { trust: 2, trustEvidence: `trusted publisher (${tp.id ?? "OIDC"})` };
132
+ if (v.dist?.attestations)
133
+ return { trust: 1, trustEvidence: "provenance attestation, no trusted publisher" };
134
+ const who = v._npmUser?.name ? ` (published by npm user "${v._npmUser.name}")` : "";
135
+ return { trust: 0, trustEvidence: `no trusted publisher or provenance attestation${who}` };
136
+ }
137
+ function installScriptsOf(v) {
138
+ const found = INSTALL_HOOKS.filter((hook) => typeof v.scripts?.[hook] === "string");
139
+ // npm runs `node-gyp rebuild` as an implicit install script when the package
140
+ // ships a binding.gyp and declares no install/preinstall of its own.
141
+ if (v.gypfile && !found.includes("install") && !found.includes("preinstall")) {
142
+ found.push("install (implicit node-gyp rebuild)");
143
+ }
144
+ if (found.length === 0 && v.hasInstallScript)
145
+ found.push("install script (hasInstallScript)");
146
+ return found;
147
+ }
148
+ function errorText(err) {
149
+ return err instanceof Error ? err.message : String(err);
150
+ }
@@ -22,57 +22,33 @@ export class NpmRegistry {
22
22
  this.rateLimiter = rateLimiter;
23
23
  }
24
24
  async getPackageInfo(name, currentVersion, isDev) {
25
- const cacheKey = `npm-reg:${name}`;
26
- const cached = this.cache.get(cacheKey);
27
- if (cached !== null)
28
- return cached;
29
- if (!this.rateLimiter.canProceed("npm")) {
30
- const stale = this.cache.getStale(cacheKey);
31
- if (stale)
32
- return stale.data;
33
- return errorResult(name, currentVersion, isDev, "Rate limited");
34
- }
35
- try {
36
- this.rateLimiter.consume("npm");
37
- const meta = await fetchJson(`${NPM_REGISTRY_URL}/${encodeURIComponent(name)}`, { headers: { Accept: "application/vnd.npm.install-v1+json" } }, NPM_TIMEOUT_MS);
38
- const latestTag = meta["dist-tags"]?.latest ?? null;
39
- const versions = Object.keys(meta.versions || {});
40
- // Latest stable = last version that is not a prerelease
41
- const latestStable = findLatestStable(versions);
42
- // Check deprecation on the latest version entry
43
- const latestVersionEntry = latestTag ? meta.versions[latestTag] : null;
44
- const deprecated = !!latestVersionEntry?.deprecated;
45
- const deprecationMessage = latestVersionEntry?.deprecated ?? null;
46
- // Semver distance between current and latest stable (or latest tag)
47
- let versionsBehind = null;
48
- if (currentVersion && (latestStable || latestTag)) {
49
- versionsBehind = computeSemverDistance(currentVersion, latestStable || latestTag);
25
+ // The cache holds facts about the PACKAGE; the answer is computed for the
26
+ // installed version on every call. It used to cache the finished answer by
27
+ // name, so the second project to ask about a package got the first one's
28
+ // `currentVersion` and distance.
29
+ const cacheKey = `npm-facts:${name}`;
30
+ let facts = this.cache.get(cacheKey);
31
+ if (facts === null) {
32
+ if (!this.rateLimiter.canProceed("npm")) {
33
+ facts = this.cache.getStale(cacheKey)?.data ?? null;
34
+ if (!facts)
35
+ return errorResult(name, currentVersion, isDev, "Rate limited");
36
+ }
37
+ else {
38
+ try {
39
+ this.rateLimiter.consume("npm");
40
+ const meta = await fetchJson(`${NPM_REGISTRY_URL}/${encodeURIComponent(name)}`, { headers: { Accept: "application/vnd.npm.install-v1+json" } }, NPM_TIMEOUT_MS);
41
+ facts = packageFacts(meta);
42
+ this.cache.set(cacheKey, facts, "npm", NPM_CACHE_TTL);
43
+ }
44
+ catch (err) {
45
+ facts = this.cache.getStale(cacheKey)?.data ?? null;
46
+ if (!facts)
47
+ return errorResult(name, currentVersion, isDev, err instanceof Error ? err.message : String(err));
48
+ }
50
49
  }
51
- const result = {
52
- name,
53
- ecosystem: "npm",
54
- currentVersion,
55
- latestVersion: latestTag,
56
- latestStableVersion: latestStable,
57
- versionsBehind,
58
- deprecated,
59
- deprecationMessage,
60
- lastPublished: meta.modified ?? null,
61
- license: null, // abbreviated metadata does not include license
62
- weeklyDownloads: null, // fetched separately via getBulkDownloads
63
- isDev,
64
- fetchError: null,
65
- };
66
- this.cache.set(cacheKey, result, "npm", NPM_CACHE_TTL);
67
- return result;
68
- }
69
- catch (err) {
70
- const stale = this.cache.getStale(cacheKey);
71
- if (stale)
72
- return stale.data;
73
- const message = err instanceof Error ? err.message : String(err);
74
- return errorResult(name, currentVersion, isDev, message);
75
50
  }
51
+ return infoFor(name, currentVersion, isDev, facts);
76
52
  }
77
53
  async getBulkDownloads(names) {
78
54
  const result = new Map();
@@ -127,10 +103,48 @@ export class NpmRegistry {
127
103
  return result;
128
104
  }
129
105
  }
130
- // Semver-max, not last-published: maintenance releases of older lines are
131
- // published after newer lines (see maxStableSemver).
132
- function findLatestStable(versions) {
133
- return maxStableSemver(versions);
106
+ function packageFacts(meta) {
107
+ const deprecations = {};
108
+ for (const [version, entry] of Object.entries(meta.versions || {})) {
109
+ if (entry?.deprecated)
110
+ deprecations[version] = entry.deprecated;
111
+ }
112
+ return {
113
+ latestTag: meta["dist-tags"]?.latest ?? null,
114
+ // Semver-max, not last-published: maintenance releases of older lines are
115
+ // published after newer lines (see maxStableSemver).
116
+ latestStable: maxStableSemver(Object.keys(meta.versions || {})),
117
+ modified: meta.modified ?? null,
118
+ deprecations,
119
+ };
120
+ }
121
+ /**
122
+ * The answer for one installed version. Deprecation is the INSTALLED
123
+ * version's first (that is what the project runs): `mkdirp@0.5.1` is
124
+ * deprecated on npm while its latest is not, and checking only the latest
125
+ * entry reported it healthy. A deprecated latest still marks the whole
126
+ * package deprecated.
127
+ */
128
+ function infoFor(name, currentVersion, isDev, facts) {
129
+ const installedDeprecation = currentVersion ? facts.deprecations[currentVersion] : undefined;
130
+ const latestDeprecation = facts.latestTag ? facts.deprecations[facts.latestTag] : undefined;
131
+ const target = facts.latestStable || facts.latestTag;
132
+ const versionsBehind = currentVersion && target ? computeSemverDistance(currentVersion, target) : null;
133
+ return {
134
+ name,
135
+ ecosystem: "npm",
136
+ currentVersion,
137
+ latestVersion: facts.latestTag,
138
+ latestStableVersion: facts.latestStable,
139
+ versionsBehind,
140
+ deprecated: Boolean(installedDeprecation || latestDeprecation),
141
+ deprecationMessage: installedDeprecation ?? latestDeprecation ?? null,
142
+ lastPublished: facts.modified,
143
+ license: null, // abbreviated metadata does not include license
144
+ weeklyDownloads: null, // fetched separately via getBulkDownloads
145
+ isDev,
146
+ fetchError: null,
147
+ };
134
148
  }
135
149
  function errorResult(name, currentVersion, isDev, fetchError) {
136
150
  return {
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import type { LiveCache } from "./cache.js";
10
10
  import type { RateLimiter } from "./rate-limiter.js";
11
- import type { ResolvedDependency, OsvVulnerability, VulnerabilityScanResult } from "./types.js";
11
+ import type { ResolvedDependency, OsvVulnerability, VulnerabilityEntry, VulnerabilityScanResult } from "./types.js";
12
12
  export declare class OsvScanner {
13
13
  private cache;
14
14
  private rateLimiter;
@@ -24,6 +24,17 @@ export declare class OsvScanner {
24
24
  private hydrateDetails;
25
25
  private fetchDetail;
26
26
  }
27
+ /**
28
+ * Overwrite the dep-context fields of a cached advisory entry with the CURRENT
29
+ * resolution's context. The advisory facts (id, severity, summary, fix version)
30
+ * are stable and cacheable; `platformActive`, `target`, `sourceDirs`, and the
31
+ * dev/direct flags are functions of TODAY's dependency resolution, not of the
32
+ * advisory. Serving them from cache bakes yesterday's context into today's
33
+ * scan — observed live 2026-08-30: a pre-platform-filter scan cached `glib`
34
+ * as platform-active and `sandbox` with no source dirs, and every scan for the
35
+ * next hour repeated both errors while the fixed resolver sat idle.
36
+ */
37
+ export declare function restampDepContext(cached: VulnerabilityEntry, dep: ResolvedDependency): VulnerabilityEntry;
27
38
  export declare function extractCvssScore(severity: Array<{
28
39
  type: string;
29
40
  score: string;
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { fetchWithTimeout, fetchJson } from "./http-utils.js";
11
11
  import { cvssBaseScore } from "./cvss.js";
12
- import { compareSemver, maxSemver, parseSemver } from "./semver-utils.js";
12
+ import { compareVersions, isComparable, maxVersion, samePackageName } from "./version-compare.js";
13
13
  const OSV_BATCH_URL = "https://api.osv.dev/v1/querybatch";
14
14
  const OSV_VULN_URL = "https://api.osv.dev/v1/vulns/";
15
15
  const OSV_TIMEOUT_MS = 15_000;
@@ -37,7 +37,7 @@ export class OsvScanner {
37
37
  const cacheKey = `osv:${dep.ecosystem}:${dep.name}:${dep.version}`;
38
38
  const cached = this.cache.get(cacheKey);
39
39
  if (cached !== null) {
40
- cachedVulns.push(...cached);
40
+ cachedVulns.push(...cached.map((v) => restampDepContext(v, dep)));
41
41
  }
42
42
  else {
43
43
  uncached.push(dep);
@@ -61,7 +61,7 @@ export class OsvScanner {
61
61
  const cacheKey = `osv:${dep.ecosystem}:${dep.name}:${dep.version}`;
62
62
  const stale = this.cache.getStale(cacheKey);
63
63
  if (stale)
64
- cachedVulns.push(...stale.data);
64
+ cachedVulns.push(...stale.data.map((v) => restampDepContext(v, dep)));
65
65
  }
66
66
  }
67
67
  }
@@ -173,6 +173,34 @@ export class OsvScanner {
173
173
  }
174
174
  }
175
175
  }
176
+ /**
177
+ * Overwrite the dep-context fields of a cached advisory entry with the CURRENT
178
+ * resolution's context. The advisory facts (id, severity, summary, fix version)
179
+ * are stable and cacheable; `platformActive`, `target`, `sourceDirs`, and the
180
+ * dev/direct flags are functions of TODAY's dependency resolution, not of the
181
+ * advisory. Serving them from cache bakes yesterday's context into today's
182
+ * scan — observed live 2026-08-30: a pre-platform-filter scan cached `glib`
183
+ * as platform-active and `sandbox` with no source dirs, and every scan for the
184
+ * next hour repeated both errors while the fixed resolver sat idle.
185
+ */
186
+ export function restampDepContext(cached, dep) {
187
+ return {
188
+ ...cached,
189
+ package: dep.name,
190
+ currentVersion: dep.version || "unknown",
191
+ ecosystem: dep.ecosystem,
192
+ isDev: dep.isDev,
193
+ isDirect: dep.isDirect,
194
+ devScopeKnown: dep.devScopeKnown,
195
+ target: dep.target,
196
+ platformActive: dep.platformActive,
197
+ sourceDirs: dep.sourceDirs ?? [],
198
+ // Install drift is today's node_modules state, not an advisory fact:
199
+ // stamped from the current dep, so a cached row never carries it forward.
200
+ installDriftOf: dep.installDriftOf,
201
+ installFix: dep.installFix,
202
+ };
203
+ }
176
204
  function mapVulnerability(vuln, dep) {
177
205
  const { severity, cvssScore } = deriveSeverity(vuln);
178
206
  const fixedVersion = extractFixedVersion(vuln.affected, dep.name, dep.ecosystem, dep.version);
@@ -196,6 +224,9 @@ function mapVulnerability(vuln, dep) {
196
224
  .slice(0, 3),
197
225
  target: dep.target,
198
226
  platformActive: dep.platformActive,
227
+ sourceDirs: dep.sourceDirs ?? [],
228
+ installDriftOf: dep.installDriftOf,
229
+ installFix: dep.installFix,
199
230
  };
200
231
  }
201
232
  /**
@@ -288,26 +319,27 @@ export function extractFixedVersion(affected, packageName, ecosystem, installedV
288
319
  .filter((f) => f !== null);
289
320
  if (fixes.length === 0)
290
321
  return null;
291
- if (installedVersion === null || parseSemver(installedVersion) === null) {
322
+ // Ordered the ecosystem's way (PEP 440 for PyPI, `v`-less semver for Go).
323
+ const cmp = (a, b) => compareVersions(a, b, ecosystem);
324
+ if (installedVersion === null || !isComparable(installedVersion, ecosystem)) {
292
325
  // Can't place the installed version on a release line — recommend the
293
326
  // highest published fix so the upgrade resolves the advisory either way.
294
- return maxSemver(fixes);
327
+ return maxVersion(fixes, ecosystem);
295
328
  }
296
- // The interval whose release line the installed version sits on. `compareSemver`
297
- // returns 0 for unparseable bounds (e.g. introduced "0" = since inception),
298
- // which keeps those intervals eligible on the introduced side only.
299
- const containing = intervals.find((i) => compareSemver(installedVersion, i.introduced) >= 0 &&
300
- (i.fixed === null || compareSemver(installedVersion, i.fixed) < 0));
301
- if (containing?.fixed &&
302
- compareSemver(containing.fixed, installedVersion) > 0) {
329
+ // The interval whose release line the installed version sits on. An
330
+ // unreadable bound compares as 0, which keeps those intervals eligible on
331
+ // the introduced side only; "0" (since inception) orders below everything.
332
+ const containing = intervals.find((i) => cmp(installedVersion, i.introduced) >= 0 &&
333
+ (i.fixed === null || cmp(installedVersion, i.fixed) < 0));
334
+ if (containing?.fixed && cmp(containing.fixed, installedVersion) > 0) {
303
335
  return containing.fixed;
304
336
  }
305
337
  // No fix on the installed line (open interval, or installed already past
306
338
  // every containing range): smallest fix strictly above installed, or nothing.
307
- const above = fixes.filter((f) => compareSemver(f, installedVersion) > 0);
339
+ const above = fixes.filter((f) => cmp(f, installedVersion) > 0);
308
340
  if (above.length === 0)
309
341
  return null;
310
- return above.reduce((min, v) => (compareSemver(v, min) < 0 ? v : min), above[0]);
342
+ return above.reduce((min, v) => (cmp(v, min) < 0 ? v : min), above[0]);
311
343
  }
312
344
  /**
313
345
  * Flatten the matching package's version ranges into [introduced, fixed)
@@ -318,7 +350,10 @@ export function extractFixedVersion(affected, packageName, ecosystem, installedV
318
350
  function collectVulnerableIntervals(affected, packageName, ecosystem) {
319
351
  const intervals = [];
320
352
  for (const a of affected) {
321
- if (a.package.name !== packageName || a.package.ecosystem !== ecosystem)
353
+ // Names compare the registry's way: PEP 503 for PyPI (`Jinja2` is
354
+ // `jinja2`), `-`/`_` folding for crates. An exact compare found no range
355
+ // for Jinja2 or PyYAML and reported "no fix version published" for both.
356
+ if (a.package.ecosystem !== ecosystem || !samePackageName(a.package.name, packageName, ecosystem))
322
357
  continue;
323
358
  for (const range of a.ranges || []) {
324
359
  if (range.type === "GIT")