diff --git a/.gitignore b/.gitignore index 1abe6d2..1b2fb6f 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ AGENTS.md # Internal local docs / ADRs .internal/ + diff --git a/package.json b/package.json index 6fc6053..5ec3e2c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@synsoftworks/depgraph-cli", - "version": "0.2.7", + "version": "0.2.8", "description": "Graph-first dependency risk analysis for npm packages and dependency trees", "type": "module", "keywords": [ diff --git a/src/application/scan-package.ts b/src/application/scan-package.ts index 6be5bf4..63cff2d 100644 --- a/src/application/scan-package.ts +++ b/src/application/scan-package.ts @@ -509,6 +509,8 @@ function buildScanReviewRecord({ name: result.root.name, version: result.root.version, } + const primaryFinding = result.findings[0] + const hasTransitivePrimaryFinding = primaryFinding !== undefined && primaryFinding.depth > 0 return { record_id: result.record_id, @@ -517,6 +519,7 @@ function buildScanReviewRecord({ package: pkg, package_key: packageKey(pkg), scan_target: result.scan_target, + ...(hasTransitivePrimaryFinding ? { primary_finding_key: primaryFinding.key } : {}), baseline_identity: baselineIdentity, baseline_key: baselineKey, baseline_record_id: baselineRecordId, @@ -525,7 +528,7 @@ function buildScanReviewRecord({ field_reliability: result.field_reliability, raw_score: result.overall_risk_score, risk_level: result.overall_risk_level, - signals: result.root.signals, + signals: hasTransitivePrimaryFinding ? [] : result.root.signals, findings: result.findings, root: result.root, total_scanned: result.total_scanned, diff --git a/src/domain/contracts.ts b/src/domain/contracts.ts index 620668a..121f827 100644 --- a/src/domain/contracts.ts +++ b/src/domain/contracts.ts @@ -138,6 +138,7 @@ export interface ScanReviewRecord { package: ResolvedPackage package_key: string scan_target: string + primary_finding_key?: string baseline_identity: BaselineIdentity baseline_key: string baseline_record_id: string | null diff --git a/src/domain/scan-review-records.ts b/src/domain/scan-review-records.ts index 55c3cd5..7c17751 100644 --- a/src/domain/scan-review-records.ts +++ b/src/domain/scan-review-records.ts @@ -37,6 +37,8 @@ export function normalizeScanReviewRecord(record: StoredScanReviewRecord): ScanR // Older records can omit package identity fields, so downstream consumers normalize from the root node first. const root = normalizePackageNode(record.root) const scanMode = record.scan_mode ?? baselineIdentity.scan_mode + const findings = (record.findings ?? []).map((finding) => normalizeScanFinding(record.record_id, finding)) + const primaryFindingKey = resolvePrimaryFindingKey(record.primary_finding_key, findings) return { ...record, @@ -49,10 +51,11 @@ export function normalizeScanReviewRecord(record: StoredScanReviewRecord): ScanR }, package_key: record.package_key ?? packageKey(record.package ?? root), scan_target: record.scan_target ?? baselineIdentity.scan_target, + ...(primaryFindingKey !== undefined ? { primary_finding_key: primaryFindingKey } : {}), baseline_identity: baselineIdentity, baseline_key: baselineKeyForIdentity(baselineIdentity), warnings: record.warnings ?? [], - findings: (record.findings ?? []).map((finding) => normalizeScanFinding(record.record_id, finding)), + findings, edge_findings: getStoredEdgeFindings(record).map((edgeFinding) => normalizeEdgeFinding(record.record_id, edgeFinding), ), @@ -109,3 +112,20 @@ function getStoredEdgeFindings(record: StoredScanReviewRecord): ScanReviewRecord // Legacy records used the earlier field name; both are accepted during normalization. return record.edge_findings ?? record.new_dependency_edge_findings ?? [] } + +function resolvePrimaryFindingKey( + storedPrimaryFindingKey: string | undefined, + findings: ScanReviewRecord['findings'], +): string | undefined { + if (typeof storedPrimaryFindingKey === 'string' && storedPrimaryFindingKey.length > 0) { + return storedPrimaryFindingKey + } + + const primaryFinding = findings[0] + + if (primaryFinding !== undefined && primaryFinding.depth > 0) { + return primaryFinding.key + } + + return undefined +} diff --git a/test/jsonl-scan-review-store.test.ts b/test/jsonl-scan-review-store.test.ts index 9831c08..205d978 100644 --- a/test/jsonl-scan-review-store.test.ts +++ b/test/jsonl-scan-review-store.test.ts @@ -32,6 +32,7 @@ test('JSONL scan review store appends records and retrieves the latest matching scanTarget: 'root', packageKey: 'root@1.1.0', workspaceIdentity: workingDirectory, + primaryFindingKey: 'grandchild@1.0.0', dependencyEdges: [ { from: 'root@1.1.0', to: 'child@1.0.0', child_depth: 1 }, { from: 'child@1.0.0', to: 'grandchild@1.0.0', child_depth: 2 }, @@ -49,6 +50,7 @@ test('JSONL scan review store appends records and retrieves the latest matching assert.equal(contents.trim().split('\n').length, 2) assert.equal(latest?.record_id, '2') + assert.equal(latest?.primary_finding_key, 'grandchild@1.0.0') assert.equal(latest?.dependency_edges.length, 2) }) @@ -164,6 +166,19 @@ test('JSONL scan review store normalizes legacy scan records without baseline id assert.equal(record?.warnings.length, 0) }) +test('JSONL scan review store backfills primary_finding_key for legacy transitive findings', async () => { + const workingDirectory = await mkdtemp(join(tmpdir(), 'depgraph-jsonl-')) + const paths = defaultScanReviewStorePaths(workingDirectory) + const store = new JsonlScanReviewStore(paths) + + await writeLegacyTransitiveFindingScanRecord(paths.scanRecordsPath) + + const records = await store.listScanRecords() + + assert.equal(records[0]?.primary_finding_key, 'legacy-child@1.0.0') + assert.equal(records[0]?.findings[0]?.depth, 1) +}) + function createRecord({ recordId, createdAt, @@ -172,6 +187,7 @@ function createRecord({ workspaceIdentity, dependencyEdges, scanMode = 'registry_package', + primaryFindingKey, }: { recordId: string createdAt: string @@ -180,6 +196,7 @@ function createRecord({ workspaceIdentity: string dependencyEdges: DependencyGraphEdge[] scanMode?: ScanReviewRecord['scan_mode'] + primaryFindingKey?: string }): ScanReviewRecord { return { record_id: recordId, @@ -188,6 +205,7 @@ function createRecord({ package: { name: 'root', version: packageKey.split('@').at(-1) ?? '1.0.0' }, package_key: packageKey, scan_target: scanTarget, + ...(primaryFindingKey !== undefined ? { primary_finding_key: primaryFindingKey } : {}), baseline_identity: { scan_mode: scanMode, scan_target: scanTarget, @@ -352,3 +370,77 @@ async function writeLegacyScanRecord(path: string): Promise { 'utf8', ) } + +async function writeLegacyTransitiveFindingScanRecord(path: string): Promise { + const { appendFile, mkdir } = await import('node:fs/promises') + const { dirname } = await import('node:path') + + await mkdir(dirname(path), { recursive: true }) + + await appendFile( + path, + `${JSON.stringify({ + record_id: 'legacy-scan-2', + created_at: '2026-04-02T00:00:00.000Z', + package: { name: 'legacy-root', version: '1.0.0' }, + package_key: 'legacy-root@1.0.0', + scan_target: 'legacy-root', + baseline_key: 'legacy-root::depth=2', + baseline_record_id: null, + requested_depth: 2, + threshold: 0.4, + raw_score: 0.48, + risk_level: 'review', + signals: [], + findings: [ + { + key: 'legacy-child@1.0.0', + name: 'legacy-child', + version: '1.0.0', + depth: 1, + path: { + packages: [ + { name: 'legacy-root', version: '1.0.0' }, + { name: 'legacy-child', version: '1.0.0' }, + ], + }, + risk_score: 0.48, + risk_level: 'review', + recommendation: 'review', + signals: [], + explanation: 'legacy transitive finding', + }, + ], + root: { + name: 'legacy-root', + version: '1.0.0', + key: 'legacy-root@1.0.0', + depth: 0, + age_days: 10, + weekly_downloads: 1000, + dependents_count: null, + deprecated_message: null, + is_security_tombstone: false, + published_at: '2026-03-22T00:00:00.000Z', + first_published: '2026-03-22T00:00:00.000Z', + last_published: '2026-03-22T00:00:00.000Z', + total_versions: 1, + dependency_count: 1, + publish_events_last_30_days: 1, + has_advisories: false, + risk_score: 0.08, + risk_level: 'safe', + signals: [], + recommendation: 'install', + dependencies: [], + }, + total_scanned: 2, + suspicious_count: 1, + safe_count: 1, + scan_duration_ms: 1, + dependency_edges: [], + warnings: [], + })}\n`, + 'utf8', + ) +} diff --git a/test/scan-package.test.ts b/test/scan-package.test.ts index a7b2f25..e68e375 100644 --- a/test/scan-package.test.ts +++ b/test/scan-package.test.ts @@ -91,6 +91,27 @@ class StubScorer implements RiskScorer { } } +class FixtureRiskScorer implements RiskScorer { + constructor( + private readonly assessments: Record>, + ) {} + + assessPackage(metadata: PackageMetadata) { + const assessment = this.assessments[`${metadata.package.name}@${metadata.package.version}`] + + if (assessment === undefined) { + return { + risk_score: 0, + risk_level: 'safe' as const, + recommendation: 'install' as const, + signals: [], + } + } + + return assessment + } +} + class InMemoryReviewStore implements ScanReviewStore { records: ScanReviewRecord[] reviewEvents: ReviewEvent[] = [] @@ -531,6 +552,7 @@ test('scan use case persists a durable scan review record after the scan complet package: { name: 'root', version: '1.0.0' }, package_key: 'root@1.0.0', scan_target: 'root', + primary_finding_key: 'child@1.0.0', baseline_identity: { scan_mode: 'registry_package', scan_target: 'root', @@ -658,6 +680,219 @@ test('scan use case persists a durable scan review record after the scan complet }) }) +test('persisted scan record clears top-level signals and stores primary_finding_key when the primary finding is transitive', async () => { + const reviewStore = new InMemoryReviewStore() + + const scanPackage = createScanPackageUseCase({ + registryTraverser: new StubRegistryTraverser({ + root_key: 'express@5.2.1', + nodes: [ + { + key: 'express@5.2.1', + package: { name: 'express', version: '5.2.1' }, + metadata: createMetadata('express', '5.2.1'), + resolved_dependencies: Object.fromEntries( + Array.from({ length: 25 }, (_, index) => [`dep-${index}`, '1.0.0']), + ), + depth: 0, + parent_key: null, + path: { + packages: [{ name: 'express', version: '5.2.1' }], + }, + }, + { + key: 'router@2.0.0', + package: { name: 'router', version: '2.0.0' }, + metadata: createMetadata('router', '2.0.0'), + depth: 1, + parent_key: 'express@5.2.1', + path: { + packages: [ + { name: 'express', version: '5.2.1' }, + { name: 'router', version: '2.0.0' }, + ], + }, + }, + { + key: 'path-to-regexp@8.4.2', + package: { name: 'path-to-regexp', version: '8.4.2' }, + metadata: createMetadata('path-to-regexp', '8.4.2'), + depth: 2, + parent_key: 'router@2.0.0', + path: { + packages: [ + { name: 'express', version: '5.2.1' }, + { name: 'router', version: '2.0.0' }, + { name: 'path-to-regexp', version: '8.4.2' }, + ], + }, + }, + ], + }), + packageLockTraverser: new StubPackageLockTraverser({ + root_key: 'express@5.2.1', + nodes: [], + }), + pnpmLockTraverser: new StubPnpmLockTraverser({ + root_key: 'express@5.2.1', + nodes: [], + }), + scorer: new FixtureRiskScorer({ + 'express@5.2.1': { + risk_score: 0.08, + risk_level: 'safe', + recommendation: 'install', + signals: [ + { + type: 'large_dependency_surface', + value: 25, + weight: 'low', + reason: 'package introduces 25 direct dependencies', + }, + ], + }, + 'router@2.0.0': { + risk_score: 0, + risk_level: 'safe', + recommendation: 'install', + signals: [], + }, + 'path-to-regexp@8.4.2': { + risk_score: 0.48, + risk_level: 'review', + recommendation: 'review', + signals: [ + { + type: 'test_signal', + value: 0.48, + weight: 'medium', + reason: 'score 0.48', + }, + ], + }, + }), + reviewStore, + now: () => new Date('2026-04-01T00:00:00.000Z'), + }) + + const result = await scanPackage({ + scan_mode: 'registry_package', + package_spec: 'express', + max_depth: 3, + threshold: 0.4, + verbose: false, + }) + const persistedRecord = reviewStore.records.at(-1) + + assert.equal(result.overall_risk_score, 0.48) + assert.equal(result.overall_risk_level, 'review') + assert.equal(result.root.risk_score, 0.08) + assert.deepEqual(result.root.signals.map((signal) => signal.type), ['large_dependency_surface']) + assert.equal(result.findings[0]?.key, 'path-to-regexp@8.4.2') + assert.equal(result.findings[0]?.depth, 2) + assert.equal(persistedRecord?.primary_finding_key, 'path-to-regexp@8.4.2') + assert.deepEqual(persistedRecord?.signals, []) +}) + +test('persisted scan record keeps root signals when the primary finding is the root package', async () => { + const reviewStore = new InMemoryReviewStore() + reviewStore.records.push( + createStoredRecord({ + scanTarget: 'express', + dependencyEdges: [], + }), + ) + + const scanPackage = createScanPackageUseCase({ + registryTraverser: new StubRegistryTraverser({ + root_key: 'express@5.2.1', + nodes: [ + { + key: 'express@5.2.1', + package: { name: 'express', version: '5.2.1' }, + metadata: createMetadata('express', '5.2.1'), + resolved_dependencies: Object.fromEntries( + Array.from({ length: 25 }, (_, index) => [`dep-${index}`, '1.0.0']), + ), + depth: 0, + parent_key: null, + path: { + packages: [{ name: 'express', version: '5.2.1' }], + }, + }, + { + key: 'child@1.0.0', + package: { name: 'child', version: '1.0.0' }, + metadata: createMetadata('child', '1.0.0'), + depth: 1, + parent_key: 'express@5.2.1', + path: { + packages: [ + { name: 'express', version: '5.2.1' }, + { name: 'child', version: '1.0.0' }, + ], + }, + }, + ], + }), + packageLockTraverser: new StubPackageLockTraverser({ + root_key: 'express@5.2.1', + nodes: [], + }), + pnpmLockTraverser: new StubPnpmLockTraverser({ + root_key: 'express@5.2.1', + nodes: [], + }), + scorer: new FixtureRiskScorer({ + 'express@5.2.1': { + risk_score: 0.08, + risk_level: 'safe', + recommendation: 'install', + signals: [ + { + type: 'large_dependency_surface', + value: 25, + weight: 'low', + reason: 'package introduces 25 direct dependencies', + }, + ], + }, + 'child@1.0.0': { + risk_score: 0, + risk_level: 'safe', + recommendation: 'install', + signals: [], + }, + }), + reviewStore, + now: () => new Date('2026-04-01T00:00:00.000Z'), + }) + + const result = await scanPackage({ + scan_mode: 'registry_package', + package_spec: 'express', + max_depth: 3, + threshold: 0.4, + verbose: false, + }) + const persistedRecord = reviewStore.records.at(-1) + + assert.equal(result.overall_risk_score, 0.4) + assert.equal(result.overall_risk_level, 'review') + assert.equal(result.root.risk_score, 0.4) + assert.deepEqual(result.root.signals.map((signal) => signal.type), [ + 'large_dependency_surface', + 'new_direct_dependency_edge', + ]) + assert.equal(result.findings[0]?.key, 'express@5.2.1') + assert.equal(result.findings[0]?.depth, 0) + assert.equal(persistedRecord?.primary_finding_key, undefined) + assert.deepEqual(persistedRecord?.signals.map((signal) => signal.type), [ + 'large_dependency_surface', + 'new_direct_dependency_edge', + ]) +}) + test('projected dependency edge delta is omitted when there is no prior scan', async () => { const reviewStore = new InMemoryReviewStore() const scanPackage = createScanPackageUseCase({ @@ -1288,10 +1523,14 @@ function createStoredRecord({ dependencyEdges, scanMode = 'registry_package', workspaceIdentity = 'local', + scanTarget = 'root', + primaryFindingKey, }: { dependencyEdges: DependencyGraphEdge[] scanMode?: ScanReviewRecord['scan_mode'] workspaceIdentity?: string + scanTarget?: string + primaryFindingKey?: string }): ScanReviewRecord { const root = createStoredPackageNode() @@ -1301,14 +1540,15 @@ function createStoredRecord({ scan_mode: scanMode, package: { name: 'root', version: '1.0.0' }, package_key: 'root@1.0.0', - scan_target: 'root', + scan_target: scanTarget, + ...(primaryFindingKey !== undefined ? { primary_finding_key: primaryFindingKey } : {}), baseline_identity: { scan_mode: scanMode, - scan_target: 'root', + scan_target: scanTarget, requested_depth: 3, workspace_identity: workspaceIdentity, }, - baseline_key: `${scanMode}::root::depth=3::workspace=${workspaceIdentity}`, + baseline_key: `${scanMode}::${scanTarget}::depth=3::workspace=${workspaceIdentity}`, baseline_record_id: null, requested_depth: 3, threshold: 0.4,