Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ AGENTS.md

# Internal local docs / ADRs
.internal/

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
5 changes: 4 additions & 1 deletion src/application/scan-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/domain/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion src/domain/scan-review-records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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),
),
Expand Down Expand Up @@ -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
}
92 changes: 92 additions & 0 deletions test/jsonl-scan-review-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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)
})

Expand Down Expand Up @@ -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,
Expand All @@ -172,6 +187,7 @@ function createRecord({
workspaceIdentity,
dependencyEdges,
scanMode = 'registry_package',
primaryFindingKey,
}: {
recordId: string
createdAt: string
Expand All @@ -180,6 +196,7 @@ function createRecord({
workspaceIdentity: string
dependencyEdges: DependencyGraphEdge[]
scanMode?: ScanReviewRecord['scan_mode']
primaryFindingKey?: string
}): ScanReviewRecord {
return {
record_id: recordId,
Expand All @@ -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,
Expand Down Expand Up @@ -352,3 +370,77 @@ async function writeLegacyScanRecord(path: string): Promise<void> {
'utf8',
)
}

async function writeLegacyTransitiveFindingScanRecord(path: string): Promise<void> {
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',
)
}
Loading
Loading