From 87184d68c7803a700c5fc49fd2c6177acc96485e Mon Sep 17 00:00:00 2001 From: saraeloop Date: Tue, 7 Apr 2026 13:30:04 -0700 Subject: [PATCH] feat(scan): polish TUI and plain-text scan presentation - refine scan presentation in console-renderer, plain-text-renderer, and scan-output-presenter - add subtle emphasis for the scanned package in the tree - move signal labels into each finding block and remove detached footer badges - align risk wording across presentation surfaces - clarify finding metadata labels such as weekly downloads and total versions - make safe dependency nodes visually softer than root and active findings - simplify the TUI finding card by removing extra structure around reasons - add/update renderer tests for: - root emphasis - per-finding signal association - consistent risk wording verification: - pnpm test - pnpm run build --- package.json | 2 +- src/interface/console-renderer.tsx | 133 +++++++------------------ src/interface/plain-text-renderer.ts | 10 +- src/interface/scan-output-presenter.ts | 47 +++++++++ test/console-renderer.test.ts | 67 ++----------- test/plain-text-renderer.test.ts | 13 +++ 6 files changed, 110 insertions(+), 162 deletions(-) diff --git a/package.json b/package.json index cfa66f5..6fc6053 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@synsoftworks/depgraph-cli", - "version": "0.2.6", + "version": "0.2.7", "description": "Graph-first dependency risk analysis for npm packages and dependency trees", "type": "module", "keywords": [ diff --git a/src/interface/console-renderer.tsx b/src/interface/console-renderer.tsx index 6ba7849..86b8844 100644 --- a/src/interface/console-renderer.tsx +++ b/src/interface/console-renderer.tsx @@ -27,7 +27,6 @@ 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 signalTags = deriveSignalTags(result) const summary = buildScanSummary(result) const exitCode = result.suspicious_count > 0 ? 1 : 0 const showOverallRisk = shouldRenderOverallRisk(result) @@ -106,20 +105,12 @@ function ScanResultView({ result }: { result: ScanResult }): React.JSX.Element { OVERALL RISK - {`${formatOverallRiskLabel(result.overall_risk_level)} · ${result.overall_risk_score.toFixed(2)}`} + {`${formatPresentedRiskLevel(result.overall_risk_level)} · ${result.overall_risk_score.toFixed(2)}`} ) : null} - {signalTags.length > 0 ? ( - - {signalTags.map((tag) => ( - - ))} - - ) : null} - Process exited with code {String(exitCode)} @@ -203,15 +194,24 @@ function TreeRow({ prefix: string finding: ScanFinding | undefined }): React.JSX.Element { - const emphatic = finding !== undefined || node.risk_level === 'critical' + const isRootNode = node.depth === 0 + const isActiveFinding = finding !== undefined + const nameColor = isActiveFinding + ? 'white' + : isRootNode + ? 'white' + : node.risk_level === 'safe' + ? 'gray' + : riskColor(node.risk_level) return ( - {prefix} - + {prefix} + {node.name} {`@${node.version}`} + {isRootNode ? {' · scanned package'} : null} {node.is_project_root ? {' · project root'} : null} {node.metadata_status === 'unresolved_registry_lookup' ? ( {' · registry metadata unavailable'} @@ -263,9 +263,9 @@ function FindingPanel({ const reasons = formatFindingReasons(finding) const findingKind = isSecurityRelatedFinding(finding) ? 'PRIORITY FINDING' : 'ROUTINE FINDING' const details: Array<[string, string, string]> = [ - ['age', formatAge(node.age_days), 'redBright'], - ['downloads', formatDownloads(node.weekly_downloads, node.is_security_tombstone), 'redBright'], - ['versions', formatPublishedVersions(node.total_versions), 'yellowBright'], + ['package age', formatAge(node.age_days), 'redBright'], + ['weekly downloads', formatDownloads(node.weekly_downloads, node.is_security_tombstone), 'redBright'], + ['total versions', formatPublishedVersions(node.total_versions), 'yellowBright'], ['risk score', `${finding.risk_score.toFixed(2)} (threshold: ${threshold.toFixed(2)})`, 'redBright'], ] @@ -295,17 +295,20 @@ function FindingPanel({ {details.map(([label, value, color]) => ( {'• '} - {`${label.padEnd(11)}`} + {`${label.padEnd(16)}`} {value} ))} - {reasons.map((reason) => ( - - {'• '} - {'reason'.padEnd(11)} - {reason} + {reasons.length > 0 ? ( + + {reasons.map((reason) => ( + + {'• '} + {reason} + + ))} - ))} + ) : null} ) @@ -316,9 +319,9 @@ function RiskBadge({ level }: { level: RiskLevel }): React.JSX.Element { return ( - - {` ${level === 'critical' ? 'suspicious' : riskLabel(level)} `} - + [ + {formatPresentedRiskLevel(level)} + ] ) } @@ -364,20 +367,6 @@ function MetricBlock({ ) } -function StatusChip({ - label, - color, -}: { - label: string - color: string -}): React.JSX.Element { - return ( - - {label} - - ) -} - function flattenTree(node: PackageNode, ancestors: boolean[] = []): Array<{ node: PackageNode; prefix: string }> { const rows = [ { @@ -420,21 +409,14 @@ 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 { + return formatPresentedRiskLevel(level) } -function riskLabel(level: RiskLevel): string { +export function formatPresentedRiskLevel(level: RiskLevel): string { switch (level) { case 'critical': - return 'suspicious' + return 'critical' case 'review': return 'review' default: @@ -442,36 +424,6 @@ function riskLabel(level: RiskLevel): string { } } -function formatOverallRiskLabel(level: RiskLevel): string { - switch (level) { - case 'critical': - return 'HIGH' - case 'review': - return 'MEDIUM' - default: - return 'LOW' - } -} - -function formatSignalLabel(type: string): string { - switch (type) { - case 'unresolved_registry_lookup': - return 'registry metadata unavailable' - case 'security_tombstone': - return 'security tombstone' - case 'new_and_unproven': - return 'zero provenance' - case 'new_package_age': - return 'new publisher' - case 'zero_downloads': - return 'zero downloads' - case 'deprecated_package': - return 'deprecated package' - default: - return type.replaceAll('_', ' ') - } -} - function formatAge(days: number | null): string { if (days === null) { return 'n/a' @@ -493,7 +445,7 @@ function formatPublishedVersions(totalVersions: number | null): string { return 'n/a' } - return `${totalVersions} published` + return `${totalVersions}` } function formatDownloads(downloads: number | null, isSecurityTombstone: boolean): string { @@ -512,23 +464,6 @@ export function shouldRenderOverallRisk(result: Pick 0 } -export function deriveSignalTags(result: Pick): string[] { - const tags = new Set() - - if (result.findings.some((finding) => finding.depth === 1)) { - tags.add('depth-1 threat') - } - - // 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) -} - export async function renderInk(result: ScanResult): Promise { const app = render() await app.waitUntilExit() diff --git a/src/interface/plain-text-renderer.ts b/src/interface/plain-text-renderer.ts index 9474d6f..b813869 100644 --- a/src/interface/plain-text-renderer.ts +++ b/src/interface/plain-text-renderer.ts @@ -12,6 +12,7 @@ import { buildScanSummary, formatEdgeFindingReason, formatFindingReasons, + formatFindingSignalLabels, partitionFindings, } from './scan-output-presenter.js' @@ -101,6 +102,7 @@ function renderFindings(findings: ScanResult['findings']): string[] { lines.push(`- ${finding.key} [${finding.risk_level} ${finding.risk_score.toFixed(2)}]`) lines.push(` Path: ${formatPath(finding.path.packages)}`) lines.push(` Target: ${finding.review_target.target_id}`) + lines.push(` Signals: ${formatFindingSignalLabels(finding).join(', ')}`) for (const reason of formatFindingReasons(finding)) { lines.push(` - ${reason}`) @@ -113,7 +115,7 @@ function renderFindings(findings: ScanResult['findings']): string[] { function renderTree(node: PackageNode, prefix = '', isLast = true): string[] { const connector = prefix.length === 0 ? '-' : isLast ? '└─' : '├─' const lines = [ - `${prefix}${connector} ${node.key}${formatNodeTags(node)} [${node.risk_level} ${node.risk_score.toFixed(2)}]`, + `${prefix}${connector} ${node.key}${formatNodeTags(node, prefix.length === 0)} [${node.risk_level} ${node.risk_score.toFixed(2)}]`, ] // Prefix state is derived during traversal so tree rows stay deterministic across environments. const childPrefix = prefix.length === 0 ? ' ' : `${prefix}${isLast ? ' ' : '│ '}` @@ -129,9 +131,13 @@ function formatPath(packages: ScanResult['findings'][number]['path']['packages'] return packages.map((pkg) => `${pkg.name}@${pkg.version}`).join(' > ') } -function formatNodeTags(node: PackageNode): string { +function formatNodeTags(node: PackageNode, isRootNode: boolean): string { const tags: string[] = [] + if (isRootNode) { + tags.push('scanned package') + } + if (node.is_project_root) { tags.push('project root') } diff --git a/src/interface/scan-output-presenter.ts b/src/interface/scan-output-presenter.ts index dd2313d..7d7ae03 100644 --- a/src/interface/scan-output-presenter.ts +++ b/src/interface/scan-output-presenter.ts @@ -206,6 +206,18 @@ export function formatFindingReasons(finding: Pick): string[] { + return deduplicate( + finding.signals.map((signal) => formatSignalLabel(signal.type)), + ) +} + /** * Formats a user-facing explanation for an edge finding. * @@ -240,6 +252,41 @@ function isSecurityRelatedSignal(signal: RiskSignal): boolean { return SECURITY_MESSAGE_PATTERN.test(value) } +function formatSignalLabel(type: string): string { + switch (type) { + case 'security_tombstone': + return 'security tombstone' + case 'security_deprecation_language': + return 'security warning' + case 'deprecated_package': + return 'deprecated package' + case 'new_and_unproven': + return 'new and unproven' + case 'new_package_age': + return 'new package age' + case 'fresh_release_on_mature_package': + return 'fresh release on mature package' + case 'low_version_history': + return 'low version history' + case 'low_weekly_downloads': + return 'low weekly downloads' + case 'zero_downloads': + return 'zero downloads' + case 'rapid_publish_churn': + return 'rapid publish churn' + case 'large_dependency_surface': + return 'large dependency surface' + case 'unresolved_registry_lookup': + return 'registry metadata unavailable' + case 'new_direct_dependency_edge': + return 'new direct dependency' + case 'new_transitive_dependency_edge': + return 'new transitive dependency' + default: + return type.replaceAll('_', ' ') + } +} + function formatSecurityDeprecationReason(signal: RiskSignal): string { const message = extractSignalText(signal) const cve = message.match(CVE_PATTERN)?.[0]?.toUpperCase() diff --git a/test/console-renderer.test.ts b/test/console-renderer.test.ts index 4882909..5cb0292 100644 --- a/test/console-renderer.test.ts +++ b/test/console-renderer.test.ts @@ -1,68 +1,15 @@ 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']) -}) +import { formatPresentedRiskLevel, shouldRenderOverallRisk } from '../src/interface/console-renderer.js' 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) }) + +test('TUI risk wording uses the public risk vocabulary consistently', () => { + assert.equal(formatPresentedRiskLevel('safe'), 'safe') + assert.equal(formatPresentedRiskLevel('review'), 'review') + assert.equal(formatPresentedRiskLevel('critical'), 'critical') +}) diff --git a/test/plain-text-renderer.test.ts b/test/plain-text-renderer.test.ts index d9930be..5b75020 100644 --- a/test/plain-text-renderer.test.ts +++ b/test/plain-text-renderer.test.ts @@ -29,6 +29,19 @@ test('plain text renderer surfaces security-related findings before routine find assert.ok(securityIndex < routineIndex) }) +test('plain text renderer marks the scanned package at the tree root', () => { + const output = renderPlainText(createResult()) + + assert.match(output, /Dependency tree:\n- root@1\.0\.0 \[scanned package\] \[critical 0\.81\]/) +}) + +test('plain text renderer associates signal labels with each finding', () => { + const output = renderPlainText(createResult()) + + assert.match(output, /Signals: deprecated package, security warning/) + assert.match(output, /Signals: new package age, low version history, zero downloads, rapid publish churn/) +}) + test('plain text renderer collapses duplicate deprecation and security language into one user-facing reason', () => { const output = renderPlainText(createResult())