diff --git a/README.md b/README.md index e2b3db0..8dd63c9 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ npm install -g @synsoftworks/depgraph-cli Run without installing: ```bash -npx -p @synsoftworks/depgraph-cli depgraph --help +npx @synsoftworks/depgraph-cli scan axios ``` ## Quick Start diff --git a/src/adapters/heuristic-risk-scorer.ts b/src/adapters/heuristic-risk-scorer.ts index cb33837..60c13fe 100644 --- a/src/adapters/heuristic-risk-scorer.ts +++ b/src/adapters/heuristic-risk-scorer.ts @@ -9,6 +9,9 @@ import { riskLevelForScore, } from '../domain/value-objects.js' +const MATURE_PACKAGE_VERSION_THRESHOLD = 100 +const MATURE_PACKAGE_DOWNLOAD_THRESHOLD = 100_000 + export class HeuristicRiskScorer implements RiskScorer { constructor(private readonly now: () => Date = () => new Date()) {} @@ -18,12 +21,22 @@ export class HeuristicRiskScorer implements RiskScorer { const dependencyCount = context.dependency_count if (ageDays <= 7) { - signals.push({ - type: 'new_package_age', - value: ageDays, - weight: 'high', - reason: `package was published ${ageDays} day(s) ago`, - }) + if (isFreshReleaseOnMaturePackage(metadata)) { + // Mature packages still get a freshness signal, but strong adoption and long version history dampen the default suspicion. + signals.push({ + type: 'fresh_release_on_mature_package', + value: ageDays, + weight: 'low', + reason: `package was published ${ageDays} day(s) ago on a mature, high-traffic package`, + }) + } else { + signals.push({ + type: 'new_package_age', + value: ageDays, + weight: 'high', + reason: `package was published ${ageDays} day(s) ago`, + }) + } } if (metadata.total_versions <= 2) { @@ -123,3 +136,11 @@ export class HeuristicRiskScorer implements RiskScorer { } } } + +function isFreshReleaseOnMaturePackage(metadata: PackageMetadata): boolean { + return ( + metadata.total_versions >= MATURE_PACKAGE_VERSION_THRESHOLD && + metadata.weekly_downloads !== null && + metadata.weekly_downloads >= MATURE_PACKAGE_DOWNLOAD_THRESHOLD + ) +} diff --git a/src/interface/console-renderer.tsx b/src/interface/console-renderer.tsx index 7327faa..6ba7849 100644 --- a/src/interface/console-renderer.tsx +++ b/src/interface/console-renderer.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react' import { Box, Text, render, useApp } from 'ink' import type { EdgeFinding } from '../domain/contracts.js' -import type { PackageNode, RiskLevel, RiskSignal, ScanFinding, ScanResult } from '../domain/entities.js' +import type { PackageNode, RiskLevel, ScanFinding, ScanResult } from '../domain/entities.js' import { buildScanSummary, formatEdgeFindingReason, @@ -27,10 +27,10 @@ function AutoExit(): React.JSX.Element | null { function ScanResultView({ result }: { result: ScanResult }): React.JSX.Element { const findingsByKey = new Map(result.findings.map((finding) => [finding.key, finding])) - const allSignals = collectSignals(result.root) - const signalTags = deriveSignalTags(result, allSignals) + const signalTags = deriveSignalTags(result) const summary = buildScanSummary(result) const exitCode = result.suspicious_count > 0 ? 1 : 0 + const showOverallRisk = shouldRenderOverallRisk(result) return ( @@ -91,7 +91,7 @@ function ScanResultView({ result }: { result: ScanResult }): React.JSX.Element { {DIVIDER} - + - - OVERALL RISK - - - {`${formatOverallRiskLabel(result.overall_risk_level)} · ${result.overall_risk_score.toFixed(2)}`} - - + {showOverallRisk ? ( + + OVERALL RISK + + + {`${formatOverallRiskLabel(result.overall_risk_level)} · ${result.overall_risk_score.toFixed(2)}`} + + + ) : null} {signalTags.length > 0 ? ( @@ -204,7 +206,7 @@ function TreeRow({ const emphatic = finding !== undefined || node.risk_level === 'critical' return ( - + {prefix} {node.name} @@ -313,14 +315,9 @@ function RiskBadge({ level }: { level: RiskLevel }): React.JSX.Element { const color = riskColor(level) return ( - - - {level === 'critical' ? ' suspicious ' : ` ${riskLabel(level)} `} + + + {` ${level === 'critical' ? 'suspicious' : riskLabel(level)} `} ) @@ -328,10 +325,8 @@ function RiskBadge({ level }: { level: RiskLevel }): React.JSX.Element { function RiskBar({ score, - level, }: { score: number - level: RiskLevel }): React.JSX.Element { const total = 28 const filled = Math.max(0, Math.min(total, Math.round(score * total))) @@ -341,12 +336,12 @@ function RiskBar({ const high = Math.max(filled - low - medium, 0) return ( - - {'█'.repeat(low)} - {'█'.repeat(medium)} - {'█'.repeat(high)} - {'█'.repeat(empty)} - + + {low > 0 ? {'█'.repeat(low)} : null} + {medium > 0 ? {'█'.repeat(medium)} : null} + {high > 0 ? {'█'.repeat(high)} : null} + {empty > 0 ? {'█'.repeat(empty)} : null} + ) } @@ -414,14 +409,6 @@ function buildTreePrefix(ancestors: boolean[]): string { return `${branchPrefix}${connector}` } -function collectSignals(node: PackageNode): RiskSignal[] { - return [node.signals, ...node.dependencies.map(collectSignals)].flat() -} - -function getMaxDepth(node: PackageNode): number { - return Math.max(node.depth, ...node.dependencies.map(getMaxDepth), 0) -} - function riskColor(level: RiskLevel): string { switch (level) { case 'critical': @@ -433,6 +420,17 @@ function riskColor(level: RiskLevel): string { } } +function riskBadgeBackground(level: RiskLevel): string { + switch (level) { + case 'critical': + return '#3a1717' + case 'review': + return '#3b2c10' + default: + return '#173420' + } +} + function riskLabel(level: RiskLevel): string { switch (level) { case 'critical': @@ -510,15 +508,22 @@ function formatDownloads(downloads: number | null, isSecurityTombstone: boolean) return `${downloads.toLocaleString()} / week` } -function deriveSignalTags(result: ScanResult, signals: RiskSignal[]): string[] { +export function shouldRenderOverallRisk(result: Pick): boolean { + return result.suspicious_count > 0 +} + +export function deriveSignalTags(result: Pick): string[] { const tags = new Set() if (result.findings.some((finding) => finding.depth === 1)) { tags.add('depth-1 threat') } - for (const signal of signals) { - tags.add(formatSignalLabel(signal.type)) + // Footer tags should only summarize visible findings, not low-weight signals from otherwise safe packages. + for (const finding of result.findings) { + for (const signal of finding.signals) { + tags.add(formatSignalLabel(signal.type)) + } } return Array.from(tags).slice(0, 6) diff --git a/src/interface/json-renderer.ts b/src/interface/json-renderer.ts index 852ce79..a6074b2 100644 --- a/src/interface/json-renderer.ts +++ b/src/interface/json-renderer.ts @@ -1,5 +1,7 @@ import type { ScanResult } from '../domain/entities.js' export function renderJson(result: ScanResult): string { - return JSON.stringify(result, null, 2) + const { field_reliability: _fieldReliability, ...publicResult } = result + + return JSON.stringify(publicResult, null, 2) } diff --git a/test/cli.test.ts b/test/cli.test.ts index 5f157cd..5039294 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -8,6 +8,7 @@ import type { FailureSurfacingSummary } from '../src/domain/failure-surfacing.js import { createFieldReliabilityReport } from '../src/domain/field-reliability-policy.js' import { NetworkFailureError } from '../src/domain/errors.js' import type { ScanResult } from '../src/domain/entities.js' +import { renderJson as renderScanJson } from '../src/interface/json-renderer.js' class MemoryStream { buffer = '' @@ -357,6 +358,40 @@ test('CLI maps network failures to exit code 3', async () => { assert.match(stderr.buffer, /registry down/) }) +test('CLI scan --json omits field reliability metadata from public output', async () => { + const stdout = new MemoryStream() + const stderr = new MemoryStream() + + const exitCode = await run(['scan', 'root', '--json'], { + scanPackage: async () => createResult(), + resolveProjectScan, + reviewScan: async () => createReviewEvent(), + evaluateFailures: async () => createFailureSurfacingSummary(), + evaluateScans: async () => createEvaluationSummary(), + renderJson: (result) => renderScanJson(result), + renderSummaryText: () => '', + renderPlainText: () => '', + renderReviewJson: () => '', + renderReviewPlainText: () => '', + renderFailureJson: () => '', + renderFailurePlainText: () => '', + renderEvaluationJson: () => '', + renderEvaluationPlainText: () => '', + renderInk: async () => {}, + stdout, + stderr, + isTty: true, + }) + + const parsed = JSON.parse(stdout.buffer) + + assert.equal(exitCode, 0) + assert.equal(parsed.field_reliability, undefined) + assert.equal(parsed.record_id, '2026-04-01T00:00:00.000Z:root@1.0.0:depth=3') + assert.equal(parsed.root.key, 'root@1.0.0') + assert.equal(stderr.buffer, '') +}) + test('CLI review command forwards explicit target ids deterministically', async () => { const stdout = new MemoryStream() const stderr = new MemoryStream() diff --git a/test/console-renderer.test.ts b/test/console-renderer.test.ts new file mode 100644 index 0000000..4882909 --- /dev/null +++ b/test/console-renderer.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import type { ScanFinding } from '../src/domain/entities.js' +import { deriveSignalTags, shouldRenderOverallRisk } from '../src/interface/console-renderer.js' + +function createFinding( + overrides: Partial = {}, +): ScanFinding { + return { + key: 'pkg@1.0.0', + name: 'pkg', + version: '1.0.0', + depth: 2, + review_target: { + kind: 'package_finding', + record_id: 'record-1', + target_id: 'package_finding:pkg@1.0.0', + finding_key: 'package_finding:pkg@1.0.0', + package_key: 'pkg@1.0.0', + }, + path: { + packages: [ + { name: 'root', version: '1.0.0' }, + { name: 'pkg', version: '1.0.0' }, + ], + }, + risk_score: 0.48, + risk_level: 'review', + recommendation: 'review', + signals: [], + explanation: 'test finding', + ...overrides, + } +} + +test('TUI signal tags only summarize surfaced findings', () => { + const tags = deriveSignalTags({ + findings: [], + }) + + assert.deepEqual(tags, []) +}) + +test('TUI signal tags are derived from finding signals and keep depth-1 context', () => { + const tags = deriveSignalTags({ + findings: [ + createFinding({ + depth: 1, + signals: [ + { + type: 'rapid_publish_churn', + value: 8, + weight: 'medium', + reason: '8 version publish events happened in the last 30 days', + }, + ], + }), + ], + }) + + assert.deepEqual(tags, ['depth-1 threat', 'rapid publish churn']) +}) + +test('TUI overall risk section is hidden when no findings exceeded threshold', () => { + assert.equal(shouldRenderOverallRisk({ suspicious_count: 0 }), false) + assert.equal(shouldRenderOverallRisk({ suspicious_count: 1 }), true) +}) diff --git a/test/heuristic-risk-scorer.test.ts b/test/heuristic-risk-scorer.test.ts index 0cfb5e0..8766a65 100644 --- a/test/heuristic-risk-scorer.test.ts +++ b/test/heuristic-risk-scorer.test.ts @@ -82,6 +82,81 @@ test('heuristic scorer flags zero-download injection pattern aggressively', () = assert.ok(result.signals.some((signal) => signal.type === 'new_and_unproven')) }) +test('heuristic scorer dampens freshness for mature high-download packages', () => { + const scorer = new HeuristicRiskScorer(() => NOW) + const metadata = createMetadata({ + package: { + name: 'caniuse-lite', + version: '1.0.30001786', + }, + published_at: '2026-03-31T00:00:00.000Z', + first_published_at: '2017-01-27T00:00:00.000Z', + last_published_at: '2026-03-31T00:00:00.000Z', + total_versions: 945, + weekly_downloads: 138_941_698, + publish_events_last_30_days: 8, + }) + + const result = scorer.assessPackage(metadata, { + depth: 0, + path: { + packages: [{ name: 'caniuse-lite', version: '1.0.30001786' }], + }, + dependency_count: 0, + }) + + assert.equal(result.risk_level, 'safe') + assert.equal(result.risk_score, 0.24) + assert.ok(!result.signals.some((signal) => signal.type === 'new_package_age')) + assert.ok(result.signals.some((signal) => signal.type === 'fresh_release_on_mature_package')) + assert.ok(result.signals.some((signal) => signal.type === 'rapid_publish_churn')) +}) + +test('heuristic scorer keeps the existing freshness signal for genuinely new packages', () => { + const scorer = new HeuristicRiskScorer(() => NOW) + const metadata = createMetadata({ + published_at: '2026-03-31T00:00:00.000Z', + total_versions: 2, + weekly_downloads: 500, + }) + + const result = scorer.assessPackage(metadata, { + depth: 1, + path: { + packages: [ + { name: 'root', version: '1.0.0' }, + { name: 'risky-package', version: '1.0.0' }, + ], + }, + dependency_count: 0, + }) + + assert.ok(result.signals.some((signal) => signal.type === 'new_package_age')) + assert.ok(!result.signals.some((signal) => signal.type === 'fresh_release_on_mature_package')) +}) + +test('heuristic scorer does not dampen freshness when weekly downloads are unknown', () => { + const scorer = new HeuristicRiskScorer(() => NOW) + const metadata = createMetadata({ + published_at: '2026-03-31T00:00:00.000Z', + total_versions: 945, + weekly_downloads: null, + publish_events_last_30_days: 0, + }) + + const result = scorer.assessPackage(metadata, { + depth: 0, + path: { + packages: [{ name: 'risky-package', version: '1.0.0' }], + }, + dependency_count: 0, + }) + + assert.equal(result.risk_score, 0.32) + assert.ok(result.signals.some((signal) => signal.type === 'new_package_age')) + assert.ok(!result.signals.some((signal) => signal.type === 'fresh_release_on_mature_package')) +}) + test('heuristic scorer treats security tombstones as critical regardless of inherited download counts', () => { const scorer = new HeuristicRiskScorer(() => NOW) const metadata = createMetadata({ diff --git a/test/json-renderer.test.ts b/test/json-renderer.test.ts index d52aaaf..cc4dd7f 100644 --- a/test/json-renderer.test.ts +++ b/test/json-renderer.test.ts @@ -137,7 +137,6 @@ test('scan JSON contract remains stable and deterministic', () => { 'baseline_record_id', 'requested_depth', 'threshold', - 'field_reliability', 'root', 'edge_findings', 'findings', @@ -201,8 +200,7 @@ test('scan JSON contract remains stable and deterministic', () => { recommendation: 'review', }) assert.equal(parsed.findings[0].key, 'child@1.0.0') - assert.equal(parsed.field_reliability.adr, 'ADR-012') - assert.equal(parsed.field_reliability.fields['package_node.weekly_downloads'].tier, 'conditionally_reliable') + assert.equal(parsed.field_reliability, undefined) assert.equal(parsed.findings[0].review_target.target_id, 'package_finding:child@1.0.0') assert.equal(parsed.findings[0].signals[0].type, 'test_signal') assert.equal(parsed.root.metadata_status, 'enriched')