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())