From 3e1f1368bc640b51a5d0cb6bd711610409791cc6 Mon Sep 17 00:00:00 2001
From: saraeloop
Date: Tue, 7 Apr 2026 15:29:15 -0700
Subject: [PATCH] feat(scan): add warning for weekly downloads lookup fallback
- track weekly downloads lookup fallback in npm package metadata fetching
- surface a single aggregated scan warning when one or more package lookups fall back to unknown
- reuse the existing warnings channel for rendering and JSON output
- avoid warning on synthetic project roots where weekly_downloads is expected to be null
- keep scoring and scan result behavior unchanged
- add coverage for:
- downloads lookup fallback
- successful downloads lookup
- synthetic project-root null handling
- stable warning rendering
verification:
- pnpm test
- pnpm run build
---
README.md | 19 ++-
src/adapters/npm-package-metadata-source.ts | 29 +++--
src/application/scan-package.ts | 15 +++
src/domain/contracts.ts | 3 +-
test/npm-package-metadata-source.test.ts | 40 ++++++
test/plain-text-renderer.test.ts | 22 ++++
test/scan-package.test.ts | 129 ++++++++++++++++++++
7 files changed, 238 insertions(+), 19 deletions(-)
diff --git a/README.md b/README.md
index 22b6a13..30a165b 100644
--- a/README.md
+++ b/README.md
@@ -10,10 +10,7 @@
-DepGraph scores npm packages and their transitive dependencies against
-behavioral signals, publish age, version velocity, registry deprecation
-and tells you exactly why something looks suspicious. Signature-based
-scanners miss what DepGraph catches by design.
+DepGraph scores npm packages and their transitive dependencies against behavioral signals, publish age, version velocity, and registry deprecation. It tells you exactly why something looks suspicious. Signature-based scanners miss what DepGraph catches by design.
Run it before every install. Use the JSON output in CI. Built for agents.
@@ -72,7 +69,7 @@ Append a review outcome to a stored scan finding:
depgraph review --target package_finding:axios@1.14.0 --outcome benign --notes "reviewed by analyst"
```
-Inspect local dataset coverage and readiness:
+Check how many of your scanned packages have full metadata enrichment versus degraded coverage:
```bash
depgraph eval
@@ -111,9 +108,9 @@ next@15.1.7
review (0.64)
-- packages requiring review: 2
+- packages requiring review: 1
- findings with security-related signals: 1
-- packages that appear safe: 12
+- packages that appear safe: 13
```
## JSON Example
@@ -191,7 +188,7 @@ Pre-v1. Interfaces may change before 1.0.
## Roadmap
-### Shipped
+### v0.2 — Shipped
- [x] npm package scanning with traversal
- [x] rich Ink terminal UI and plain text mode
@@ -205,15 +202,15 @@ Pre-v1. Interfaces may change before 1.0.
- [x] local dataset evaluation
- [x] depgraph.sh
-### Coming Soon
+### v0.3 — In Progress
- [ ] yarn lockfile support
-- [ ] sensitive import analysis
- [ ] explain command
- [ ] CI/CD GitHub Action
-### Future
+### Later
+- [ ] sensitive import analysis
- [ ] maintainer history signals
- [ ] organization-level scan aggregation
diff --git a/src/adapters/npm-package-metadata-source.ts b/src/adapters/npm-package-metadata-source.ts
index 97ae60f..4325788 100644
--- a/src/adapters/npm-package-metadata-source.ts
+++ b/src/adapters/npm-package-metadata-source.ts
@@ -27,13 +27,18 @@ interface DownloadResponse {
downloads?: number
}
+interface WeeklyDownloadsResult {
+ downloads: number | null
+ lookup_failed: boolean
+}
+
export class NpmPackageMetadataSource implements PackageMetadataSource {
constructor(private readonly fetcher: typeof fetch = fetch) {}
async resolvePackage(spec: PackageSpec): Promise {
- const [packument, weeklyDownloadsCandidate] = await Promise.all([
+ const [packument, weeklyDownloadsResult] = await Promise.all([
this.fetchPackument(spec.name),
- this.fetchWeeklyDownloads(spec.name).catch(() => null),
+ this.fetchWeeklyDownloads(spec.name),
])
const version = this.resolveVersion(packument, spec)
const manifest = packument.versions?.[version]
@@ -47,7 +52,7 @@ export class NpmPackageMetadataSource implements PackageMetadataSource {
const publishEventsLast30Days = this.countRecentPublishes(versionTimes, 30)
const deprecatedMessage = manifest.deprecated ?? null
const isSecurityTombstone = this.isSecurityTombstone(spec.name, version, deprecatedMessage)
- const weeklyDownloads = isSecurityTombstone ? null : weeklyDownloadsCandidate
+ const weeklyDownloads = isSecurityTombstone ? null : weeklyDownloadsResult.downloads
return {
package: {
@@ -61,6 +66,7 @@ export class NpmPackageMetadataSource implements PackageMetadataSource {
total_versions: Object.keys(packument.versions ?? {}).length,
publish_events_last_30_days: publishEventsLast30Days,
weekly_downloads: weeklyDownloads,
+ downloads_lookup_failed: isSecurityTombstone ? false : weeklyDownloadsResult.lookup_failed,
deprecated_message: deprecatedMessage,
is_security_tombstone: isSecurityTombstone,
has_advisories: false,
@@ -221,21 +227,30 @@ export class NpmPackageMetadataSource implements PackageMetadataSource {
)
}
- private async fetchWeeklyDownloads(name: string): Promise {
+ private async fetchWeeklyDownloads(name: string): Promise {
const url = `https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(name)}`
try {
const response = await this.fetcher(url)
if (!response.ok) {
- return null
+ return {
+ downloads: null,
+ lookup_failed: true,
+ }
}
const payload = (await response.json()) as DownloadResponse
- return typeof payload.downloads === 'number' ? payload.downloads : null
+ return {
+ downloads: typeof payload.downloads === 'number' ? payload.downloads : null,
+ lookup_failed: typeof payload.downloads !== 'number',
+ }
} catch {
- return null
+ return {
+ downloads: null,
+ lookup_failed: true,
+ }
}
}
diff --git a/src/application/scan-package.ts b/src/application/scan-package.ts
index af988f4..6be5bf4 100644
--- a/src/application/scan-package.ts
+++ b/src/application/scan-package.ts
@@ -97,6 +97,7 @@ export function createScanPackageUseCase({
const nodeMap = new Map()
const pendingFindings: PendingScanFinding[] = []
const warnings: ScanWarning[] = []
+ let hasDownloadsLookupFallback = false
let overallRiskScore = 0
for (const traversedNode of traversedGraph.nodes) {
@@ -120,6 +121,9 @@ export function createScanPackageUseCase({
lockfile_integrity: traversedNode.lockfile_integrity ?? null,
})
}
+ if (traversedNode.metadata?.downloads_lookup_failed === true) {
+ hasDownloadsLookupFallback = true
+ }
nodeMap.set(traversedNode.key, packageNode)
overallRiskScore = Math.max(overallRiskScore, assessment.risk_score)
@@ -155,6 +159,17 @@ export function createScanPackageUseCase({
const completedAt = now()
const rootNode = nodeMap.get(traversedGraph.root_key)!
+ if (hasDownloadsLookupFallback) {
+ warnings.push({
+ kind: 'weekly_downloads_unavailable',
+ package_key: rootNode.key,
+ package_name: rootNode.name,
+ package_version: rootNode.version,
+ message: 'weekly downloads unavailable for one or more packages',
+ lockfile_resolved_url: null,
+ lockfile_integrity: null,
+ })
+ }
const recordId = `${completedAt.toISOString()}:${packageKey({
name: rootNode.name,
version: rootNode.version,
diff --git a/src/domain/contracts.ts b/src/domain/contracts.ts
index 462c870..620668a 100644
--- a/src/domain/contracts.ts
+++ b/src/domain/contracts.ts
@@ -77,6 +77,7 @@ export interface PackageMetadata {
total_versions: number
publish_events_last_30_days: number
weekly_downloads: number | null
+ downloads_lookup_failed?: boolean
deprecated_message: string | null
is_security_tombstone: boolean
has_advisories: boolean
@@ -121,7 +122,7 @@ export interface EdgeFinding {
}
export interface ScanWarning {
- kind: 'unresolved_registry_lookup'
+ kind: 'unresolved_registry_lookup' | 'weekly_downloads_unavailable'
package_key: string
package_name: string
package_version: string
diff --git a/test/npm-package-metadata-source.test.ts b/test/npm-package-metadata-source.test.ts
index 5eb64a0..22c2413 100644
--- a/test/npm-package-metadata-source.test.ts
+++ b/test/npm-package-metadata-source.test.ts
@@ -94,6 +94,46 @@ test('metadata source marks npm security tombstones and ignores inherited downlo
assert.equal(metadata.is_security_tombstone, true)
assert.match(metadata.deprecated_message ?? '', /security placeholder/i)
assert.equal(metadata.weekly_downloads, null)
+ assert.equal(metadata.downloads_lookup_failed, false)
+})
+
+test('metadata source marks weekly downloads as unavailable when the downloads endpoint fails', async () => {
+ const source = new NpmPackageMetadataSource(async (input) => {
+ const url = String(input)
+
+ if (url.startsWith('https://api.npmjs.org/downloads/')) {
+ return new Response('upstream unavailable', { status: 503 })
+ }
+
+ return new Response(
+ JSON.stringify({
+ name: 'demo-package',
+ 'dist-tags': {
+ latest: '1.2.3',
+ },
+ versions: {
+ '1.2.3': {
+ version: '1.2.3',
+ dependencies: {},
+ },
+ },
+ time: {
+ created: '2014-09-02T01:28:28.167Z',
+ modified: '2024-01-15T10:22:33.000Z',
+ '1.2.3': '2024-01-15T10:22:33.000Z',
+ },
+ }),
+ {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ },
+ )
+ })
+
+ const metadata = await source.resolvePackage({ name: 'demo-package' })
+
+ assert.equal(metadata.weekly_downloads, null)
+ assert.equal(metadata.downloads_lookup_failed, true)
})
test('metadata source throws when publish timestamps are unavailable', async () => {
diff --git a/test/plain-text-renderer.test.ts b/test/plain-text-renderer.test.ts
index 5b75020..9301c2c 100644
--- a/test/plain-text-renderer.test.ts
+++ b/test/plain-text-renderer.test.ts
@@ -75,6 +75,28 @@ test('plain text renderer omits the warnings section when there are no warnings'
assert.doesNotMatch(output, /\nWarnings:\n- none/)
})
+test('plain text renderer keeps aggregated download warnings in the existing warnings section', () => {
+ const result = createResult()
+ result.warnings = [
+ {
+ kind: 'weekly_downloads_unavailable',
+ package_key: 'root@1.0.0',
+ package_name: 'root',
+ package_version: '1.0.0',
+ message: 'weekly downloads unavailable for one or more packages',
+ lockfile_resolved_url: null,
+ lockfile_integrity: null,
+ },
+ ]
+
+ const output = renderPlainText(result)
+
+ assert.match(
+ output,
+ /- root@1\.0\.0 \[weekly_downloads_unavailable\] weekly downloads unavailable for one or more packages/,
+ )
+})
+
test('plain text renderer does not mutate finding explanations or signals', () => {
const result = createResult()
const before = JSON.parse(JSON.stringify(result))
diff --git a/test/scan-package.test.ts b/test/scan-package.test.ts
index d87f2c5..a7b2f25 100644
--- a/test/scan-package.test.ts
+++ b/test/scan-package.test.ts
@@ -172,6 +172,7 @@ function createMetadata(name: string, version: string): PackageMetadata {
total_versions: 3,
publish_events_last_30_days: 1,
weekly_downloads: 1000,
+ downloads_lookup_failed: false,
deprecated_message: null,
is_security_tombstone: false,
has_advisories: false,
@@ -1124,6 +1125,134 @@ test('package_lock scans add a low-weight unresolved_registry_lookup signal with
assert.equal(result.root.dependencies[0]?.signals[0]?.weight, 'low')
})
+test('registry scans surface an aggregated warning when weekly downloads lookup falls back to unknown', async () => {
+ const reviewStore = new InMemoryReviewStore()
+ const scanPackage = createScanPackageUseCase({
+ registryTraverser: new StubRegistryTraverser({
+ root_key: 'root@1.0.0',
+ nodes: [
+ {
+ key: 'root@1.0.0',
+ package: { name: 'root', version: '1.0.0' },
+ metadata: createMetadata('root', '1.0.0'),
+ depth: 0,
+ parent_key: null,
+ path: {
+ packages: [{ name: 'root', version: '1.0.0' }],
+ },
+ },
+ {
+ key: 'child@1.0.0',
+ package: { name: 'child', version: '1.0.0' },
+ metadata: {
+ ...createMetadata('child', '1.0.0'),
+ weekly_downloads: null,
+ downloads_lookup_failed: true,
+ },
+ depth: 1,
+ parent_key: 'root@1.0.0',
+ path: {
+ packages: [
+ { name: 'root', version: '1.0.0' },
+ { name: 'child', version: '1.0.0' },
+ ],
+ },
+ },
+ ],
+ }),
+ packageLockTraverser: new StubPackageLockTraverser(createLinearGraph()),
+ pnpmLockTraverser: new StubPnpmLockTraverser(createLinearGraph()),
+ scorer: new StubScorer({}),
+ reviewStore,
+ now: () => new Date('2026-04-01T00:00:00.000Z'),
+ })
+
+ const result = await scanPackage({
+ scan_mode: 'registry_package',
+ package_spec: 'root',
+ max_depth: 3,
+ threshold: 0.4,
+ verbose: false,
+ workspace_identity: 'local',
+ })
+
+ assert.deepEqual(result.warnings, [
+ {
+ kind: 'weekly_downloads_unavailable',
+ package_key: 'root@1.0.0',
+ package_name: 'root',
+ package_version: '1.0.0',
+ message: 'weekly downloads unavailable for one or more packages',
+ lockfile_resolved_url: null,
+ lockfile_integrity: null,
+ },
+ ])
+})
+
+test('successful metadata enrichment does not emit a weekly downloads warning', async () => {
+ const reviewStore = new InMemoryReviewStore()
+ const scanPackage = createScanPackageUseCase({
+ registryTraverser: new StubRegistryTraverser(createLinearGraph()),
+ packageLockTraverser: new StubPackageLockTraverser(createLinearGraph()),
+ pnpmLockTraverser: new StubPnpmLockTraverser(createLinearGraph()),
+ scorer: new StubScorer({}),
+ reviewStore,
+ now: () => new Date('2026-04-01T00:00:00.000Z'),
+ })
+
+ const result = await scanPackage({
+ scan_mode: 'registry_package',
+ package_spec: 'root',
+ max_depth: 3,
+ threshold: 0.4,
+ verbose: false,
+ workspace_identity: 'local',
+ })
+
+ assert.deepEqual(result.warnings, [])
+})
+
+test('synthetic project roots do not emit weekly downloads warnings for their expected null root metadata', async () => {
+ const reviewStore = new InMemoryReviewStore()
+ const scanPackage = createScanPackageUseCase({
+ registryTraverser: new StubRegistryTraverser(createLinearGraph()),
+ packageLockTraverser: new StubPackageLockTraverser({
+ root_key: 'project@1.0.0',
+ nodes: [
+ {
+ key: 'project@1.0.0',
+ package: { name: 'project', version: '1.0.0' },
+ metadata: createMetadata('project', '1.0.0'),
+ depth: 0,
+ parent_key: null,
+ path: {
+ packages: [{ name: 'project', version: '1.0.0' }],
+ },
+ is_virtual_root: true,
+ },
+ ],
+ }),
+ pnpmLockTraverser: new StubPnpmLockTraverser(createLinearGraph()),
+ scorer: new StubScorer({}),
+ reviewStore,
+ now: () => new Date('2026-04-01T00:00:00.000Z'),
+ })
+
+ const result = await scanPackage({
+ scan_mode: 'package_lock',
+ package_lock_path: '/tmp/project/package-lock.json',
+ project_root: '/tmp/project',
+ max_depth: 3,
+ threshold: 0.4,
+ verbose: false,
+ workspace_identity: 'local',
+ })
+
+ assert.equal(result.root.is_project_root, true)
+ assert.equal(result.root.weekly_downloads, null)
+ assert.deepEqual(result.warnings, [])
+})
+
function createLinearGraph(): TraversedDependencyGraph {
return {
root_key: 'root@1.0.0',