diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26551ac..c6b14d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: commander: specifier: ^14.0.3 version: 14.0.3 + gitrole: + specifier: ^0.7.0 + version: 0.7.0 ink: specifier: ^5.0.0 version: 5.2.1(@types/react@18.3.28)(react@18.3.1) @@ -289,6 +292,11 @@ packages: get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + gitrole@0.7.0: + resolution: {integrity: sha512-QC9gHNzJSMrqnS8JAf/qnhwLWAzVufxRyqFIOtYJUzvcD5de5DPkGKKYk/7v79TmgEaH16O9MRqViblnhUdVHg==} + engines: {node: '>=20'} + hasBin: true + indent-string@5.0.0: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} @@ -606,6 +614,11 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + gitrole@0.7.0: + dependencies: + chalk: 5.6.2 + commander: 14.0.3 + indent-string@5.0.0: {} ink@5.2.1(@types/react@18.3.28)(react@18.3.1): diff --git a/src/adapters/benchmark-manifest-loader.ts b/src/adapters/benchmark-manifest-loader.ts index 03ccb1d..8d3851a 100644 --- a/src/adapters/benchmark-manifest-loader.ts +++ b/src/adapters/benchmark-manifest-loader.ts @@ -11,11 +11,13 @@ import { } from '../domain/benchmark.js' import { InvalidUsageError, StorageFailureError } from '../domain/errors.js' +/** Default benchmark manifest location used by the benchmark CLI flow. */ export const DEFAULT_BENCHMARK_MANIFEST_PATH = resolve( process.cwd(), '.internal/benchmarks/benchmark-manifest.json', ) +/** JSON-backed benchmark manifest loader. */ export class JsonBenchmarkManifestLoader implements BenchmarkManifestLoader { constructor(private readonly manifestPath = DEFAULT_BENCHMARK_MANIFEST_PATH) {} diff --git a/src/adapters/heuristic-risk-scorer.ts b/src/adapters/heuristic-risk-scorer.ts index 242bd0f..e72c98b 100644 --- a/src/adapters/heuristic-risk-scorer.ts +++ b/src/adapters/heuristic-risk-scorer.ts @@ -12,6 +12,7 @@ import { const MATURE_PACKAGE_VERSION_THRESHOLD = 100 const MATURE_PACKAGE_DOWNLOAD_THRESHOLD = 100_000 +/** Default additive heuristic scorer for package metadata signals. */ export class HeuristicRiskScorer implements RiskScorer { constructor(private readonly now: () => Date = () => new Date()) {} diff --git a/src/adapters/jsonl-scan-review-store.ts b/src/adapters/jsonl-scan-review-store.ts index 4a543a9..f29f842 100644 --- a/src/adapters/jsonl-scan-review-store.ts +++ b/src/adapters/jsonl-scan-review-store.ts @@ -15,6 +15,7 @@ interface JsonlScanReviewStorePaths { reviewEventsPath: string } +/** JSONL-backed implementation of the scan review store. */ export class JsonlScanReviewStore implements ScanReviewStore { constructor(private readonly paths: JsonlScanReviewStorePaths) {} @@ -88,6 +89,12 @@ export class JsonlScanReviewStore implements ScanReviewStore { } } +/** + * Returns the default repo-local JSONL persistence paths. + * + * @param workingDirectory Working directory used to anchor `.depgraph`. + * @returns Paths for scan and review history files. + */ export function defaultScanReviewStorePaths(workingDirectory: string): JsonlScanReviewStorePaths { // Keep append-only history repo-local and inspectable instead of hiding mutable state in a user-global cache. return { diff --git a/src/adapters/lockfile-dependency-traversal.ts b/src/adapters/lockfile-dependency-traversal.ts index 7d5cc91..c087925 100644 --- a/src/adapters/lockfile-dependency-traversal.ts +++ b/src/adapters/lockfile-dependency-traversal.ts @@ -7,6 +7,7 @@ import type { } from '../domain/ports.js' import { packageKey } from '../domain/value-objects.js' +/** Normalized dependency entry extracted from a lockfile snapshot. */ export interface NormalizedLockfileEntry { entry_id: string name: string @@ -16,6 +17,7 @@ export interface NormalizedLockfileEntry { integrity: string | null } +/** Shared lockfile traversal interface consumed by lockfile adapters. */ export interface NormalizedLockfileProject { root_package: PackageMetadata['package'] root_dependencies: Record @@ -38,6 +40,14 @@ interface QueueItem { // converts the root into an explicit project-root PackageNode with nullable package metadata. const SYNTHETIC_ROOT_PUBLISHED_AT = '1970-01-01T00:00:00.000Z' +/** + * Traverses a normalized lockfile project into the shared traversed-graph shape. + * + * @param project Normalized lockfile project. + * @param metadataSource Metadata source for exact package enrichment. + * @param maxDepth Maximum dependency depth to traverse. + * @returns Traversed dependency graph. + */ export async function traverseNormalizedLockfileProject( project: NormalizedLockfileProject, metadataSource: PackageMetadataSource, diff --git a/src/adapters/npm-package-metadata-source.ts b/src/adapters/npm-package-metadata-source.ts index 4325788..05e8ee8 100644 --- a/src/adapters/npm-package-metadata-source.ts +++ b/src/adapters/npm-package-metadata-source.ts @@ -32,6 +32,7 @@ interface WeeklyDownloadsResult { lookup_failed: boolean } +/** npm-registry-backed package metadata source. */ export class NpmPackageMetadataSource implements PackageMetadataSource { constructor(private readonly fetcher: typeof fetch = fetch) {} diff --git a/src/adapters/package-lock-dependency-traverser.ts b/src/adapters/package-lock-dependency-traverser.ts index 283a1fb..710aa0e 100644 --- a/src/adapters/package-lock-dependency-traverser.ts +++ b/src/adapters/package-lock-dependency-traverser.ts @@ -43,6 +43,7 @@ interface IndexedPackageEntry { integrity: string | null } +/** `package-lock.json` traverser that projects resolved dependencies into the shared graph shape. */ export class PackageLockDependencyTraverser implements PackageLockDependencyTraverserPort { constructor(private readonly metadataSource: PackageMetadataSource) {} diff --git a/src/adapters/parsers/pnpm-lockfile-parser.ts b/src/adapters/parsers/pnpm-lockfile-parser.ts index 992563c..f1da9ce 100644 --- a/src/adapters/parsers/pnpm-lockfile-parser.ts +++ b/src/adapters/parsers/pnpm-lockfile-parser.ts @@ -36,6 +36,13 @@ type PnpmDependencyReference = specifier?: string } +/** + * Parses a pnpm lockfile into the normalized project traversal interface. + * + * @param pnpmLockPath Path to `pnpm-lock.yaml`. + * @param projectRoot Project root whose importer should be scanned. + * @returns Normalized lockfile project. + */ export function parsePnpmLockfile( pnpmLockPath: string, projectRoot: string, diff --git a/src/adapters/pnpm-lock-dependency-traverser.ts b/src/adapters/pnpm-lock-dependency-traverser.ts index 1044e06..2ba38e2 100644 --- a/src/adapters/pnpm-lock-dependency-traverser.ts +++ b/src/adapters/pnpm-lock-dependency-traverser.ts @@ -6,6 +6,7 @@ import type { import { parsePnpmLockfile } from './parsers/pnpm-lockfile-parser.js' import { traverseNormalizedLockfileProject } from './lockfile-dependency-traversal.js' +/** `pnpm-lock.yaml` traverser that projects importer dependencies into the shared graph shape. */ export class PnpmLockDependencyTraverser implements PnpmLockDependencyTraverserPort { constructor(private readonly metadataSource: PackageMetadataSource) {} diff --git a/src/adapters/project-scan-resolver.ts b/src/adapters/project-scan-resolver.ts index 5895685..7166cbb 100644 --- a/src/adapters/project-scan-resolver.ts +++ b/src/adapters/project-scan-resolver.ts @@ -3,20 +3,24 @@ import { dirname, join, resolve } from 'node:path' import { InvalidUsageError } from '../domain/errors.js' +/** Resolved project scan request for `package-lock.json`. */ export interface PackageLockScanResolution { scan_mode: 'package_lock' project_root: string package_lock_path: string } +/** Resolved project scan request for `pnpm-lock.yaml`. */ export interface PnpmLockScanResolution { scan_mode: 'pnpm_lock' project_root: string pnpm_lock_path: string } +/** Any supported resolved project scan request. */ export type ProjectScanResolution = PackageLockScanResolution | PnpmLockScanResolution +/** Filesystem-based resolver for local project scan inputs. */ export class NodeProjectScanResolver { async resolve(projectPath: string): Promise { const projectRoot = resolve(projectPath) @@ -55,6 +59,12 @@ export class NodeProjectScanResolver { } } +/** + * Creates an explicit package-lock scan resolution from a file path. + * + * @param packageLockPath Path to `package-lock.json`. + * @returns Resolved package-lock scan input. + */ export function resolvePackageLockScan(packageLockPath: string): PackageLockScanResolution { const resolvedPackageLockPath = resolve(packageLockPath) @@ -65,6 +75,12 @@ export function resolvePackageLockScan(packageLockPath: string): PackageLockScan } } +/** + * Creates an explicit pnpm-lock scan resolution from a file path. + * + * @param pnpmLockPath Path to `pnpm-lock.yaml`. + * @returns Resolved pnpm-lock scan input. + */ export function resolvePnpmLockScan(pnpmLockPath: string): PnpmLockScanResolution { const resolvedPnpmLockPath = resolve(pnpmLockPath) diff --git a/src/adapters/registry-dependency-traverser.ts b/src/adapters/registry-dependency-traverser.ts index aa9fa1a..b79f1ea 100644 --- a/src/adapters/registry-dependency-traverser.ts +++ b/src/adapters/registry-dependency-traverser.ts @@ -14,6 +14,7 @@ interface QueueItem { path_packages: TraversedPackageNode['path']['packages'] } +/** Breadth-first registry dependency traverser using resolved package metadata. */ export class RegistryDependencyTraverser implements RegistryDependencyTraverserPort { constructor(private readonly metadataSource: PackageMetadataSource) {} diff --git a/src/adapters/scan-runner.ts b/src/adapters/scan-runner.ts index 28e8f00..08d6911 100644 --- a/src/adapters/scan-runner.ts +++ b/src/adapters/scan-runner.ts @@ -4,6 +4,7 @@ import { resolve } from 'node:path' import type { BenchmarkScanRunner } from '../domain/benchmark.js' import type { ScanResult } from '../domain/entities.js' +/** Benchmark scan runner that shells out to the built CLI. */ export class CliBenchmarkScanRunner implements BenchmarkScanRunner { constructor( private readonly cliEntryPoint = resolve(process.cwd(), 'dist/cli/index.js'), diff --git a/src/application/evaluate-benchmark-case.ts b/src/application/evaluate-benchmark-case.ts index 1991e98..07d0c06 100644 --- a/src/application/evaluate-benchmark-case.ts +++ b/src/application/evaluate-benchmark-case.ts @@ -6,10 +6,18 @@ import type { } from '../domain/benchmark.js' import type { ScanResult } from '../domain/entities.js' +/** Dependencies required to evaluate one benchmark case. */ export interface EvaluateBenchmarkCaseDependencies { scanRunner: BenchmarkScanRunner } +/** + * Evaluates one benchmark case by running a scan and comparing expected outcomes. + * + * @param benchmarkCase Benchmark case to evaluate. + * @param dependencies Runtime dependency for executing scans. + * @returns Benchmark result describing pass, fail, or skipped status. + */ export async function evaluateBenchmarkCase( benchmarkCase: BenchmarkCase, dependencies: EvaluateBenchmarkCaseDependencies, @@ -77,6 +85,12 @@ export async function evaluateBenchmarkCase( } } +/** + * Maps a scan result to the benchmark priority vocabulary. + * + * @param scanResult Completed scan result. + * @returns Expected-priority equivalent for benchmark assertions. + */ export function mapPriorityFromScan(scanResult: ScanResult): ExpectedPriority { if (scanResult.root.risk_score >= scanResult.threshold) { return 'high_priority_review' @@ -89,6 +103,12 @@ export function mapPriorityFromScan(scanResult: ScanResult): ExpectedPriority { return 'safe' } +/** + * Extracts stable actual signal identifiers from a scan result. + * + * @param scanResult Completed scan result. + * @returns Sorted unique signal identifiers from root signals and warnings. + */ export function extractActualSignals(scanResult: ScanResult): string[] { const signals = new Set() @@ -103,6 +123,13 @@ export function extractActualSignals(scanResult: ScanResult): string[] { return [...signals].sort((left, right) => left.localeCompare(right)) } +/** + * Computes which expected benchmark signals were missing from the actual scan output. + * + * @param expectedSignals Expected signal identifiers from the benchmark case. + * @param actualSignals Actual signal identifiers extracted from a scan result. + * @returns Missing expected signals. + */ export function findMissingSignals(expectedSignals: string[], actualSignals: string[]): string[] { const actualSignalSet = new Set(actualSignals) diff --git a/src/application/evaluate-scans.ts b/src/application/evaluate-scans.ts index e4e100c..3e48d75 100644 --- a/src/application/evaluate-scans.ts +++ b/src/application/evaluate-scans.ts @@ -13,6 +13,12 @@ interface EvaluateScansDependencies { resolveReviewStateIndex: () => Promise> } +/** + * Creates the evaluation use case for persisted scans and review history. + * + * @param dependencies Runtime dependencies for scan records, review events, and resolved review state. + * @returns Use case that aggregates evaluation summary output. + */ export function createEvaluateScansUseCase({ scanRecordSource, rawReviewEventSource, diff --git a/src/application/evaluation-readiness.ts b/src/application/evaluation-readiness.ts index 98a59b4..e4e66bd 100644 --- a/src/application/evaluation-readiness.ts +++ b/src/application/evaluation-readiness.ts @@ -19,6 +19,10 @@ import type { SignalFrequency, } from '../domain/contracts.js' import type { PackageNode, RiskSignal } from '../domain/entities.js' +import { + getPackageNodeMetadataFieldState, + isObservedMetadataField, +} from '../domain/metadata-field-state.js' import { isSecurityRelatedDeprecation } from '../domain/security-deprecation.js' // Export-readiness exclusions are single-reason buckets. This precedence keeps @@ -200,6 +204,8 @@ export function buildEvaluationDatasetSummary(scanRecords: ScanReviewRecord[]): // they are structural roots rather than real published packages. for (const node of flattenMetadataNodes(record.root)) { totalNodes += 1 + const dependentsCountState = getPackageNodeMetadataFieldState(node, 'dependents_count') + const advisoriesState = getPackageNodeMetadataFieldState(node, 'has_advisories') if (node.weekly_downloads === null) { nodesMissingWeeklyDownloads += 1 @@ -208,7 +214,7 @@ export function buildEvaluationDatasetSummary(scanRecords: ScanReviewRecord[]): collectSignals(node.signals, knownDownloadsSignalCounts) } - if (node.dependents_count === null) { + if (!isObservedMetadataField(dependentsCountState)) { nodesMissingDependentsCount += 1 dependentsCountUnavailableCount += 1 collectSignals(node.signals, missingDependentsSignalCounts) @@ -216,7 +222,10 @@ export function buildEvaluationDatasetSummary(scanRecords: ScanReviewRecord[]): collectSignals(node.signals, knownDependentsSignalCounts) } - if (node.has_advisories === false) { + if ( + advisoriesState.observation === 'unavailable' + && advisoriesState.reason === 'not_collected_yet' + ) { hasAdvisoriesPlaceholderCount += 1 } diff --git a/src/application/resolve-review-state.ts b/src/application/resolve-review-state.ts index 917a956..8e4496f 100644 --- a/src/application/resolve-review-state.ts +++ b/src/application/resolve-review-state.ts @@ -86,6 +86,13 @@ export function buildResolvedReviewStateIndex( return resolvedStates } +/** + * Returns the resolved review state for a target, defaulting to an unreviewed state. + * + * @param reviewTarget Review target to resolve. + * @param resolvedReviewStateIndex Precomputed resolved review-state index. + * @returns Resolved review state for the target. + */ export function getResolvedReviewState( reviewTarget: ReviewTarget, resolvedReviewStateIndex: ReadonlyMap, diff --git a/src/application/review-scan.ts b/src/application/review-scan.ts index 70ca7b6..c286c5d 100644 --- a/src/application/review-scan.ts +++ b/src/application/review-scan.ts @@ -7,6 +7,12 @@ interface ReviewScanDependencies { now?: () => Date } +/** + * Creates the review append use case for stored scan records. + * + * @param dependencies Runtime dependencies for persistence and time. + * @returns Use case that appends a review event. + */ export function createReviewScanUseCase({ reviewStore, now = () => new Date(), diff --git a/src/application/run-benchmark-suite.ts b/src/application/run-benchmark-suite.ts index 2ed4ee0..57480d1 100644 --- a/src/application/run-benchmark-suite.ts +++ b/src/application/run-benchmark-suite.ts @@ -5,11 +5,18 @@ import type { } from '../domain/benchmark.js' import { evaluateBenchmarkCase } from './evaluate-benchmark-case.js' +/** Dependencies required to run the benchmark suite. */ export interface RunBenchmarkSuiteDependencies { manifestLoader: BenchmarkManifestLoader scanRunner: BenchmarkScanRunner } +/** + * Runs the full benchmark suite and summarizes the results. + * + * @param dependencies Runtime dependencies for loading cases and executing scans. + * @returns Benchmark suite result with per-case output and aggregate counts. + */ export async function runBenchmarkSuite( dependencies: RunBenchmarkSuiteDependencies, ): Promise { diff --git a/src/application/scan-package.ts b/src/application/scan-package.ts index 58cf510..a8f39a5 100644 --- a/src/application/scan-package.ts +++ b/src/application/scan-package.ts @@ -61,6 +61,12 @@ interface DependencyEdgeSnapshot { type PendingEdgeFinding = Omit type PendingScanFinding = Omit +/** + * Creates the main scan use case for registry and lockfile-backed scans. + * + * @param dependencies Runtime dependencies for traversal, scoring, and persistence. + * @returns Use case that executes a scan and persists material history. + */ export function createScanPackageUseCase({ registryTraverser, packageLockTraverser, @@ -656,10 +662,22 @@ function resolvedDependenciesForNode(traversedNode: TraversedPackageNode): Recor return traversedNode.resolved_dependencies ?? traversedNode.metadata?.dependencies ?? {} } +/** + * Maps a scan result to the CLI process exit code for suspicious findings. + * + * @param result Completed scan result. + * @returns `1` when suspicious findings exist, otherwise `0`. + */ export function isSuspiciousExitCode(result: ScanResult): number { return result.suspicious_count > 0 ? 1 : 0 } +/** + * Returns the canonical root package key for a scan result. + * + * @param result Completed scan result. + * @returns Root package key in `name@version` form. + */ export function getRootKey(result: ScanResult): string { return packageKey({ name: result.root.name, diff --git a/src/cli/index.ts b/src/cli/index.ts index 3e9f604..9b1caec 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -20,6 +20,7 @@ interface WritableStreamLike { write(text: string): void } +/** Runtime contract injected into the CLI entrypoint. */ export interface CliRuntime { scanPackage: (request: ScanRequest) => Promise resolveProjectScan: (projectPath: string) => Promise @@ -41,6 +42,13 @@ export interface CliRuntime { isTty: boolean } +/** + * Runs the DepGraph CLI with the provided argv and runtime overrides. + * + * @param argv User argv without the node executable prefix. + * @param overrides Optional runtime overrides for tests or embedding. + * @returns Process exit code. + */ export async function run(argv: string[], overrides: Partial = {}): Promise { const runtime = await createRuntime(overrides) const program = new Command() diff --git a/src/domain/benchmark.ts b/src/domain/benchmark.ts index c149da9..f46700f 100644 --- a/src/domain/benchmark.ts +++ b/src/domain/benchmark.ts @@ -1,5 +1,6 @@ import type { ScanResult } from './entities.js' +/** Supported benchmark package availability states. */ export const BENCHMARK_AVAILABILITIES = [ 'live', 'tombstoned', @@ -8,16 +9,22 @@ export const BENCHMARK_AVAILABILITIES = [ 'private_registry_only', ] as const +/** Availability classification for a benchmark case. */ export type BenchmarkAvailability = (typeof BENCHMARK_AVAILABILITIES)[number] +/** Expected benchmark priorities used in assertions. */ export const EXPECTED_PRIORITIES = ['safe', 'normal', 'high_priority_review'] as const +/** Expected priority outcome for a benchmark case. */ export type ExpectedPriority = (typeof EXPECTED_PRIORITIES)[number] +/** Terminal benchmark execution statuses. */ export const BENCHMARK_STATUSES = ['PASS', 'FAIL', 'SKIPPED'] as const +/** Status for one evaluated benchmark case. */ export type BenchmarkStatus = (typeof BENCHMARK_STATUSES)[number] +/** One benchmark manifest entry. */ export interface BenchmarkCase { id: string package: string @@ -28,6 +35,7 @@ export interface BenchmarkCase { expected_signals: string[] } +/** Result of running one benchmark case. */ export interface BenchmarkResult { id: string package: string @@ -44,6 +52,7 @@ export interface BenchmarkResult { failure_reason: string | null } +/** Aggregate counts for a benchmark suite run. */ export interface BenchmarkSummary { pass: number fail: number @@ -51,15 +60,18 @@ export interface BenchmarkSummary { total: number } +/** Full result set for a benchmark suite run. */ export interface BenchmarkSuiteResult { results: BenchmarkResult[] summary: BenchmarkSummary } +/** Port for loading benchmark cases from a manifest source. */ export interface BenchmarkManifestLoader { loadManifest(): Promise } +/** Port for executing a benchmark scan. */ export interface BenchmarkScanRunner { runScan(packageSpec: string): Promise } diff --git a/src/domain/contracts.ts b/src/domain/contracts.ts index 121f827..30b3fea 100644 --- a/src/domain/contracts.ts +++ b/src/domain/contracts.ts @@ -10,21 +10,25 @@ import type { ScanFinding, } from './entities.js' +/** Parsed npm package input with an optional requested version range. */ export interface PackageSpec { name: string version_range?: string } +/** Exact resolved package identity. */ export interface ResolvedPackage { name: string version: string } +/** Dependency path captured under the current projected traversal model. */ export interface DependencyPath { // v1 paths follow the current BFS tree projection, not every possible parent path in the underlying DAG. packages: ResolvedPackage[] } +/** Availability state of package metadata for a traversed node. */ export type PackageMetadataStatus = | 'enriched' | 'unresolved_registry_lookup' @@ -32,8 +36,10 @@ export type PackageMetadataStatus = // Scan mode records which structural source produced the scan. // Baseline matching and downstream analysis must keep these source types separate. +/** Structural source used to build a scan. */ export type ScanMode = 'registry_package' | 'package_lock' | 'pnpm_lock' +/** Request for a registry-backed package scan. */ export interface RegistryPackageScanRequest { scan_mode: 'registry_package' package_spec: string @@ -43,6 +49,7 @@ export interface RegistryPackageScanRequest { workspace_identity?: string } +/** Request for a project scan sourced from `package-lock.json`. */ export interface PackageLockScanRequest { scan_mode: 'package_lock' package_lock_path: string @@ -53,6 +60,7 @@ export interface PackageLockScanRequest { workspace_identity?: string } +/** Request for a project scan sourced from `pnpm-lock.yaml`. */ export interface PnpmLockScanRequest { scan_mode: 'pnpm_lock' pnpm_lock_path: string @@ -63,10 +71,13 @@ export interface PnpmLockScanRequest { workspace_identity?: string } +/** Any supported lockfile-backed project scan request. */ export type ProjectScanRequest = PackageLockScanRequest | PnpmLockScanRequest +/** Any supported scan request accepted by the application layer. */ export type ScanRequest = RegistryPackageScanRequest | ProjectScanRequest +/** Registry metadata and coarse ecosystem signals for one resolved package. */ export interface PackageMetadata { package: ResolvedPackage dependencies: Record @@ -84,6 +95,7 @@ export interface PackageMetadata { dependents_count: number | null } +/** Heuristic assessment produced by a risk scorer for one package. */ export interface RiskAssessment { risk_score: number risk_level: RiskLevel @@ -91,6 +103,7 @@ export interface RiskAssessment { signals: RiskSignal[] } +/** Dependency edge preserved from the current traversal projection. */ export interface DependencyGraphEdge { // Despite the name, v1 stores edges from the current BFS dependency tree projection. // Shared packages collapsed by the traverser may have additional real parents that are not represented here. @@ -99,6 +112,7 @@ export interface DependencyGraphEdge { child_depth: number } +/** Baseline identity used to match comparable historical scans. */ export interface BaselineIdentity { // Cross-mode baselines are invalid even when target and workspace match. scan_mode: ScanMode @@ -107,6 +121,7 @@ export interface BaselineIdentity { workspace_identity: string } +/** Newly introduced dependency edge relative to a matching baseline scan. */ export interface EdgeFinding { // Edge findings describe newly introduced projected edges relative to the latest matching baseline scan. parent_key: string @@ -121,6 +136,7 @@ export interface EdgeFinding { recommendation: Recommendation | null } +/** Scan-time warning about incomplete or degraded evidence. */ export interface ScanWarning { kind: 'unresolved_registry_lookup' | 'weekly_downloads_unavailable' package_key: string @@ -131,6 +147,7 @@ export interface ScanWarning { lockfile_integrity: string | null } +/** Persisted append-only scan snapshot stored for review and baseline workflows. */ export interface ScanReviewRecord { record_id: string created_at: string @@ -160,6 +177,7 @@ export interface ScanReviewRecord { warnings: ScanWarning[] } +/** Command payload for appending a review event to a stored scan. */ export interface ReviewScanRequest { record_id: string target_id?: string @@ -169,6 +187,7 @@ export interface ReviewScanRequest { confidence: number | null } +/** Supported kinds of review target. */ export type ReviewTargetKind = 'package_finding' | 'edge_finding' interface ReviewTargetBase { @@ -177,12 +196,14 @@ interface ReviewTargetBase { target_id: string } +/** Review target identifying a package-level scan finding. */ export interface PackageFindingReviewTarget extends ReviewTargetBase { kind: 'package_finding' finding_key: string package_key: string } +/** Review target identifying a newly introduced dependency edge. */ export interface EdgeFindingReviewTarget extends ReviewTargetBase { kind: 'edge_finding' edge_finding_key: string @@ -191,6 +212,7 @@ export interface EdgeFindingReviewTarget extends ReviewTargetBase { edge_type: EdgeFinding['edge_type'] } +/** Any persisted finding target that can receive review events. */ export type ReviewTarget = PackageFindingReviewTarget | EdgeFindingReviewTarget /** @@ -211,9 +233,12 @@ export interface ReviewEvent { confidence: number | null } +/** Canonical label derived from review history. */ export type CanonicalLabel = 'malicious' | 'benign' +/** Workflow state derived from review history. */ export type WorkflowStatus = 'unreviewed' | 'needs_review' | 'resolved' // Current canonical-label policy: higher-trust sources win; recency only breaks ties within a source tier. +/** Policy used to derive canonical labels from raw events. */ export type CanonicalLabelSource = 'source_precedence_then_latest_within_source' /** @@ -232,22 +257,26 @@ export interface ResolvedReviewTargetState { canonical_label_source: CanonicalLabelSource | null } +/** Frequency counter for a signal type across stored scans. */ export interface SignalFrequency { type: string count: number } +/** Coverage summary for one metadata field across package nodes. */ export interface MetadataFieldCoverage { total_nodes: number missing_count: number missing_percent: number } +/** Signal-frequency split by whether a metadata field was present. */ export interface CoverageSignalFrequency { known: SignalFrequency[] missing: SignalFrequency[] } +/** Metadata coverage section of evaluation output. */ export interface MetadataCoverageSummary { weekly_downloads: MetadataFieldCoverage dependents_count: MetadataFieldCoverage @@ -255,6 +284,7 @@ export interface MetadataCoverageSummary { signal_frequency_by_dependents_count: CoverageSignalFrequency } +/** Exact ADR-012 field-tier counts across eligible records. */ export interface FieldReliabilityDistributionSummary { records_with_field_reliability: number records_excluded_missing_field_reliability: number @@ -267,18 +297,21 @@ export interface FieldReliabilityDistributionSummary { scan_context: number } +/** Integrity counters for known structural and metadata boundary cases. */ export interface IntegritySignalsSummary { synthetic_project_root_count: number unresolved_registry_lookup_count: number deprecated_with_security_signal_count: number } +/** Counters for placeholder and unavailable fields that block export readiness. */ export interface FieldReadinessIssuesSummary { dependents_count_unavailable_count: number has_advisories_placeholder_count: number records_missing_field_reliability_count: number } +/** Counts of heuristic outputs already materialized on persisted nodes. */ export interface HeuristicOutputPresenceSummary { nodes_with_risk_score: number nodes_with_risk_level: number @@ -286,6 +319,7 @@ export interface HeuristicOutputPresenceSummary { nodes_with_signals: number } +/** Readiness totals for future deterministic dataset export. */ export interface ExportReadinessSummary { records_total: number records_with_field_reliability: number @@ -306,6 +340,7 @@ export interface ExportReadinessSummary { } } +/** Raw review-event counters before canonical label derivation. */ export interface RawReviewEventSummary { total_events: number malicious_events: number @@ -313,6 +348,7 @@ export interface RawReviewEventSummary { needs_review_events: number } +/** Canonical label counts after resolved-state derivation. */ export interface CanonicalLabelSummary { total_labeled_targets: number malicious_targets: number @@ -321,18 +357,21 @@ export interface CanonicalLabelSummary { derived_from: CanonicalLabelSource } +/** Workflow-status counts derived from resolved review state. */ export interface WorkflowStatusSummary { unreviewed_targets: number needs_review_targets: number resolved_targets: number } +/** Review-target counts across stored scan history. */ export interface ReviewTargetSummary { total_targets: number package_finding_targets: number edge_finding_targets: number } +/** Aggregate output returned by the evaluation use case. */ export interface EvaluationSummary { total_scans: number review_targets: ReviewTargetSummary diff --git a/src/domain/entities.ts b/src/domain/entities.ts index ae671b7..e55368d 100644 --- a/src/domain/entities.ts +++ b/src/domain/entities.ts @@ -7,11 +7,17 @@ import type { ScanWarning, } from './contracts.js' +/** Severity band derived from a package risk score. */ export type RiskLevel = 'safe' | 'review' | 'critical' +/** Install guidance derived from a package risk level. */ export type Recommendation = 'install' | 'review' | 'do_not_install' +/** Relative contribution of an individual risk signal. */ export type RiskSignalWeight = 'low' | 'medium' | 'high' | 'critical' +/** Review outcome captured in review history. */ export type ReviewOutcome = 'malicious' | 'benign' | 'needs_review' +/** Origin of a review decision. */ export type ReviewSource = 'human' | 'auto' | 'external' +/** Reliability tier assigned by ADR-012 field policy. */ export type FieldReliabilityTier = | 'reliable' | 'conditionally_reliable' @@ -21,17 +27,20 @@ export type FieldReliabilityTier = | 'structural_only' | 'scan_context' +/** Reliability guidance for a single exported field. */ export interface FieldReliabilityEntry { tier: FieldReliabilityTier guidance: string notes?: string[] } +/** ADR-012 field reliability policy snapshot attached to a scan result. */ export interface FieldReliabilityReport { adr: 'ADR-012' fields: Record } +/** One heuristic signal contributing to a package risk assessment. */ export interface RiskSignal { type: string value: string | number | boolean | null @@ -39,6 +48,7 @@ export interface RiskSignal { reason: string } +/** Materialized dependency node in the rendered scan tree. */ export interface PackageNode { name: string version: string @@ -70,6 +80,7 @@ export interface PackageNode { dependencies: PackageNode[] } +/** Suspicious package finding surfaced from the scanned dependency view. */ export interface ScanFinding { key: string name: string @@ -84,6 +95,7 @@ export interface ScanFinding { explanation: string } +/** Full application-layer result returned by a completed scan. */ export interface ScanResult { record_id: string scan_mode: ScanMode diff --git a/src/domain/errors.ts b/src/domain/errors.ts index 8e1bfe2..9ca4fd1 100644 --- a/src/domain/errors.ts +++ b/src/domain/errors.ts @@ -1,3 +1,4 @@ +/** Raised when the caller supplies invalid input or unsupported usage. */ export class InvalidUsageError extends Error { constructor(message: string) { super(message) @@ -5,6 +6,7 @@ export class InvalidUsageError extends Error { } } +/** Raised when registry or network-backed lookups fail. */ export class NetworkFailureError extends Error { constructor(message: string) { super(message) @@ -12,6 +14,7 @@ export class NetworkFailureError extends Error { } } +/** Raised when local persistence or file access fails. */ export class StorageFailureError extends Error { constructor(message: string) { super(message) diff --git a/src/domain/failure-surfacing.ts b/src/domain/failure-surfacing.ts index 9af334e..b7d423d 100644 --- a/src/domain/failure-surfacing.ts +++ b/src/domain/failure-surfacing.ts @@ -1,7 +1,10 @@ +/** Classification of a surfaced benchmark failure. */ export type SurfacedFailureClass = 'underweighted_signal' | 'missing_signal' +/** Status explaining why a surfaced failure is being reported. */ export type SurfacedFailureStatus = 'historical_match' | 'known_boundary_case' +/** One surfaced historical or known-boundary failure entry. */ export interface SurfacedFailure { package: string version: string @@ -11,12 +14,14 @@ export interface SurfacedFailure { reason: string } +/** Aggregate output of the failure-surfacing evaluation flow. */ export interface FailureSurfacingSummary { total_records_scanned: number total_matches: number failures: SurfacedFailure[] } +/** Hand-maintained known boundary case that should still surface in reports. */ export interface KnownBoundaryCase { package: string version: string @@ -25,6 +30,7 @@ export interface KnownBoundaryCase { reason: string } +/** Known benchmark boundary cases that are expected to miss current metadata-only heuristics. */ export const KNOWN_BOUNDARY_CASES: KnownBoundaryCase[] = [ { package: 'isite', diff --git a/src/domain/field-reliability-policy.ts b/src/domain/field-reliability-policy.ts index d37bd51..4620ce5 100644 --- a/src/domain/field-reliability-policy.ts +++ b/src/domain/field-reliability-policy.ts @@ -1,5 +1,10 @@ import type { FieldReliabilityEntry, FieldReliabilityReport } from './entities.js' +/** + * Builds the current ADR-012 field reliability policy snapshot. + * + * @returns Field reliability report used in scan results and readiness analysis. + */ export function createFieldReliabilityReport(): FieldReliabilityReport { return { adr: 'ADR-012', @@ -82,6 +87,8 @@ export function createFieldReliabilityReport(): FieldReliabilityReport { ), 'package_node.dependents_count': unavailable( 'Exclude from analysis and export until collection is implemented.', + 'Interpret raw values through the metadata field-state helpers.', + 'Do not treat null as zero or as a benign missing feature value.', ), 'package_node.deprecated_message': reliable('Safe for analysis and feature use.'), 'package_node.is_security_tombstone': reliable('Safe for analysis and feature use.'), @@ -93,6 +100,8 @@ export function createFieldReliabilityReport(): FieldReliabilityReport { 'package_node.publish_events_last_30_days': reliable('Safe for analysis and feature use.'), 'package_node.has_advisories': placeholder( 'Exclude from analysis and export until advisory ingestion is implemented.', + 'Interpret raw values through the metadata field-state helpers.', + 'Do not treat false as observed clean advisory status.', ), 'package_node.risk_score': heuristicOutput( 'Valid for UI and debugging; do not use as ground truth or labels.', diff --git a/src/domain/metadata-field-state.ts b/src/domain/metadata-field-state.ts new file mode 100644 index 0000000..93bc3e6 --- /dev/null +++ b/src/domain/metadata-field-state.ts @@ -0,0 +1,146 @@ +import type { PackageNode } from './entities.js' + +/** Observation state for a metadata field after missingness interpretation. */ +export type MetadataFieldObservation = + | 'observed_present' + | 'observed_absent' + | 'unavailable' + | 'not_applicable' + +/** Reason explaining why a metadata field has its current observation state. */ +export type MetadataFieldStateReason = + | 'explicit_value' + | 'not_collected_yet' + | 'registry_metadata_unavailable' + | 'synthetic_project_root' + +/** Interpreted metadata-field state for downstream export and modeling code. */ +export interface MetadataFieldState { + observation: MetadataFieldObservation + value: T | null + reason: MetadataFieldStateReason +} + +/** + * Central contract for metadata-field missingness. + * + * Raw scan fields can look ordinary even when DepGraph has not collected them + * yet. Downstream export/modeling code must interpret ambiguous fields through + * these helpers instead of inferring meaning directly from `null` or `false`. + * + * This contract distinguishes between "observed absence" and "not collected yet". + * Fields that are not currently ingested MUST return `unavailable`, not + * `observed_absent`. + * + * `observed_absent` is part of the contract for fields that are explicitly + * checked clean, but current advisory ingestion does not produce that state yet. + */ +export type PackageNodeMetadataField = 'dependents_count' | 'has_advisories' + +/** + * Creates a state representing an observed non-missing metadata value. + * + * @param value Observed field value. + * @returns Metadata field state marked as observed present. + */ +export function observedPresentMetadataFieldState(value: T): MetadataFieldState { + return { + observation: 'observed_present', + value, + reason: 'explicit_value', + } +} + +/** + * Creates a state representing an observed explicit absence. + * + * @param value Field value that encodes a checked-clean or otherwise observed absence. + * @returns Metadata field state marked as observed absent. + */ +export function observedAbsentMetadataFieldState(value: T): MetadataFieldState { + return { + observation: 'observed_absent', + value, + reason: 'explicit_value', + } +} + +/** + * Creates a state representing an unavailable metadata value. + * + * @param reason Reason the field is unavailable. + * @returns Metadata field state marked as unavailable. + */ +export function unavailableMetadataFieldState( + reason: Extract< + MetadataFieldStateReason, + 'not_collected_yet' | 'registry_metadata_unavailable' + >, +): MetadataFieldState { + return { + observation: 'unavailable', + value: null, + reason, + } +} + +/** Creates a state representing a field that does not apply to the current node. */ +export function notApplicableMetadataFieldState(): MetadataFieldState { + return { + observation: 'not_applicable', + value: null, + reason: 'synthetic_project_root', + } +} + +/** + * Checks whether a metadata field was actually observed. + * + * @param state Interpreted metadata field state. + * @returns `true` when the field was observed present or observed absent. + */ +export function isObservedMetadataField(state: MetadataFieldState): boolean { + return ( + state.observation === 'observed_present' + || state.observation === 'observed_absent' + ) +} + +export function getPackageNodeMetadataFieldState( + node: PackageNode, + field: 'dependents_count', +): MetadataFieldState +export function getPackageNodeMetadataFieldState( + node: PackageNode, + field: 'has_advisories', +): MetadataFieldState +/** + * Interprets a raw package-node metadata field through the missingness contract. + * + * @param node Package node carrying raw field values. + * @param field Metadata field to interpret. + * @returns Interpreted field state suitable for export and modeling code. + */ +export function getPackageNodeMetadataFieldState( + node: PackageNode, + field: PackageNodeMetadataField, +): MetadataFieldState { + if (node.is_project_root || node.metadata_status === 'synthetic_project_root') { + return notApplicableMetadataFieldState() + } + + if (node.metadata_status === 'unresolved_registry_lookup') { + return unavailableMetadataFieldState('registry_metadata_unavailable') + } + + switch (field) { + case 'dependents_count': + return node.dependents_count === null + ? unavailableMetadataFieldState('not_collected_yet') + : observedPresentMetadataFieldState(node.dependents_count) + case 'has_advisories': + return node.has_advisories === true + ? observedPresentMetadataFieldState(true) + : unavailableMetadataFieldState('not_collected_yet') + } +} diff --git a/src/domain/ports.ts b/src/domain/ports.ts index 47fb8dc..24c96fa 100644 --- a/src/domain/ports.ts +++ b/src/domain/ports.ts @@ -10,6 +10,7 @@ import type { ScanReviewRecord, } from './contracts.js' +/** Traversed package node emitted by dependency adapters before application projection. */ export interface TraversedPackageNode { key: string package: PackageMetadata['package'] @@ -25,42 +26,50 @@ export interface TraversedPackageNode { is_virtual_root?: boolean } +/** Traversed dependency structure produced by a dependency adapter. */ export interface TraversedDependencyGraph { // v1 traversal returns a breadth-first tree projection keyed by first-seen resolved packages. root_key: string nodes: TraversedPackageNode[] } +/** Port for resolving registry metadata for an exact package. */ export interface PackageMetadataSource { resolvePackage(spec: PackageSpec): Promise } +/** Port for registry-backed dependency traversal. */ export interface RegistryDependencyTraverser { traverse(root: PackageSpec, max_depth: number): Promise } +/** Port for package-lock-based dependency traversal. */ export interface PackageLockDependencyTraverser { // v1 package-lock scanning reads resolved dependency structure from package-lock.json itself. // It currently supports lockfileVersion 2+ with a packages map. traverse(package_lock_path: string, max_depth: number): Promise } +/** Port for pnpm-lock-based dependency traversal. */ export interface PnpmLockDependencyTraverser { // v1 pnpm scanning reads resolved dependency structure from pnpm-lock.yaml itself. // It currently supports importer-scoped project scans backed by a packages snapshot map. traverse(pnpm_lock_path: string, project_root: string, max_depth: number): Promise } +/** Context available to the risk scorer for a traversed package. */ export interface RiskScorerContext { depth: number path: DependencyPath dependency_count: number } +/** Port for heuristic package risk scoring. */ export interface RiskScorer { assessPackage(metadata: PackageMetadata, context: RiskScorerContext): RiskAssessment } +/** Persistence port for scan history and review events. */ export interface ScanReviewStore { appendScanRecord(record: ScanReviewRecord): Promise findLatestScanByBaseline(baselineIdentity: BaselineIdentity): Promise diff --git a/src/domain/review-targets.ts b/src/domain/review-targets.ts index 9a9572b..a937b2a 100644 --- a/src/domain/review-targets.ts +++ b/src/domain/review-targets.ts @@ -7,10 +7,24 @@ import type { } from './contracts.js' import type { ScanFinding } from './entities.js' +/** + * Builds the stable target id for a package finding. + * + * @param packageKey Exact package key for the finding. + * @returns Target id in `package_finding:` form. + */ export function packageFindingTargetId(packageKey: string): string { return `package_finding:${packageKey}` } +/** + * Builds the stable target id for an edge finding. + * + * @param parentKey Parent package key. + * @param childKey Child package key. + * @param edgeType Edge kind relative to the root. + * @returns Target id for the edge finding. + */ export function edgeFindingTargetId( parentKey: string, childKey: string, @@ -19,6 +33,13 @@ export function edgeFindingTargetId( return `edge_finding:${edgeType}:${parentKey}->${childKey}` } +/** + * Creates a normalized review target for a package finding. + * + * @param recordId Owning scan record id. + * @param packageKey Exact package key for the finding. + * @returns Package-finding review target. + */ export function createPackageFindingReviewTarget( recordId: string, packageKey: string, @@ -34,6 +55,15 @@ export function createPackageFindingReviewTarget( } } +/** + * Creates a normalized review target for an edge finding. + * + * @param recordId Owning scan record id. + * @param parentKey Parent package key. + * @param childKey Child package key. + * @param edgeType Edge kind relative to the root. + * @returns Edge-finding review target. + */ export function createEdgeFindingReviewTarget( recordId: string, parentKey: string, @@ -53,6 +83,13 @@ export function createEdgeFindingReviewTarget( } } +/** + * Ensures a package finding carries a normalized review target. + * + * @param recordId Owning scan record id. + * @param finding Scan finding that may predate explicit review targets. + * @returns Finding with a guaranteed review target. + */ export function normalizeScanFinding(recordId: string, finding: ScanFinding): ScanFinding { const reviewTarget = finding.review_target ?? @@ -64,6 +101,13 @@ export function normalizeScanFinding(recordId: string, finding: ScanFinding): Sc } } +/** + * Ensures an edge finding carries a normalized review target. + * + * @param recordId Owning scan record id. + * @param edgeFinding Edge finding that may predate explicit review targets. + * @returns Edge finding with a guaranteed review target. + */ export function normalizeEdgeFinding(recordId: string, edgeFinding: EdgeFinding): EdgeFinding { const reviewTarget = edgeFinding.review_target ?? @@ -80,10 +124,23 @@ export function normalizeEdgeFinding(recordId: string, edgeFinding: EdgeFinding) } } +/** + * Builds the persisted lookup key for resolved review-state indexes. + * + * @param reviewTarget Review target identity. + * @returns Stable scope key combining record id and target id. + */ export function reviewTargetScopeKey(reviewTarget: ReviewTarget): string { return `${reviewTarget.record_id}::${reviewTarget.target_id}` } +/** + * Narrows a string value to a specific review-target kind. + * + * @param value Candidate review-target kind. + * @param expectedKind Expected kind. + * @returns `true` when the value matches the expected kind. + */ export function isReviewTargetKind( value: string, expectedKind: ReviewTargetKind, diff --git a/src/domain/security-deprecation.ts b/src/domain/security-deprecation.ts index 152e53a..ff3d335 100644 --- a/src/domain/security-deprecation.ts +++ b/src/domain/security-deprecation.ts @@ -3,6 +3,12 @@ const SECURITY_RELATED_DEPRECATION_PATTERN = /\b(?:security|vulnerab(?:ility|ilities)|cve-\d{4}-\d+)\b/i +/** + * Detects whether a deprecation message should be treated as security-related. + * + * @param message Deprecation message from package metadata or persisted history. + * @returns `true` when the message includes security language or a structured CVE identifier. + */ export function isSecurityRelatedDeprecation(message: string): boolean { return SECURITY_RELATED_DEPRECATION_PATTERN.test(message) } diff --git a/src/domain/value-objects.ts b/src/domain/value-objects.ts index 7333d92..da0e6d3 100644 --- a/src/domain/value-objects.ts +++ b/src/domain/value-objects.ts @@ -5,8 +5,11 @@ import type { Recommendation, RiskLevel, RiskSignal } from './entities.js' import { InvalidUsageError } from './errors.js' import { isSecurityRelatedDeprecation } from './security-deprecation.js' +/** Default maximum dependency depth for scans. */ export const DEFAULT_MAX_DEPTH = 3 +/** Default risk threshold for surfacing findings. */ export const DEFAULT_THRESHOLD = 0.4 +/** Additive weights used by the default heuristic risk scorer. */ export const RISK_SIGNAL_WEIGHTS = { low: 0.08, medium: 0.16, @@ -14,6 +17,12 @@ export const RISK_SIGNAL_WEIGHTS = { critical: 0.55, } as const +/** + * Parses a package spec into name and optional version range components. + * + * @param input Raw user-supplied package spec. + * @returns Parsed package spec. + */ export function parsePackageSpec(input: string): PackageSpec { const trimmed = input.trim() @@ -65,6 +74,12 @@ export function parsePackageSpec(input: string): PackageSpec { } } +/** + * Validates and normalizes a requested scan depth. + * + * @param value Candidate depth value. + * @returns Normalized depth. + */ export function normalizeMaxDepth(value: number): number { if (!Number.isInteger(value) || value < 0) { throw new InvalidUsageError('Depth must be a non-negative integer.') @@ -73,6 +88,12 @@ export function normalizeMaxDepth(value: number): number { return value } +/** + * Validates and normalizes a finding threshold. + * + * @param value Candidate threshold value. + * @returns Threshold rounded to two decimal places. + */ export function normalizeThreshold(value: number): number { if (!Number.isFinite(value) || value < 0 || value > 1) { throw new InvalidUsageError('Threshold must be a number between 0 and 1.') @@ -81,16 +102,37 @@ export function normalizeThreshold(value: number): number { return Number(value.toFixed(2)) } +/** + * Formats an exact resolved package into the canonical package key. + * + * @param pkg Resolved package identity. + * @returns Stable package key in `name@version` form. + */ export function packageKey(pkg: ResolvedPackage): string { return `${pkg.name}@${pkg.version}` } +/** + * Normalizes a scan target string for baseline identity and persistence. + * + * @param input Raw package spec. + * @returns Canonical scan target string. + */ export function normalizeScanTarget(input: string): string { const parsed = parsePackageSpec(input) return parsed.version_range === undefined ? parsed.name : `${parsed.name}@${parsed.version_range}` } +/** + * Builds the baseline identity for a completed scan. + * + * @param scanMode Structural scan source. + * @param scanTarget Canonical scan target. + * @param requestedDepth Requested traversal depth. + * @param workspaceIdentity Optional workspace identifier. + * @returns Baseline identity used for history lookup. + */ export function baselineIdentityForScan( scanMode: ScanMode, scanTarget: string, @@ -105,10 +147,23 @@ export function baselineIdentityForScan( } } +/** + * Formats a baseline identity into its persisted lookup key. + * + * @param identity Baseline identity. + * @returns Stable baseline key string. + */ export function baselineKeyForIdentity(identity: BaselineIdentity): string { return `${identity.scan_mode}::${identity.scan_target}::depth=${identity.requested_depth}::workspace=${identity.workspace_identity}` } +/** + * Resolves a project scan target from a project name or filesystem root. + * + * @param projectName Optional package name from lockfile or package.json. + * @param projectRoot Filesystem root for the scanned project. + * @returns Canonical project scan target. + */ export function normalizeProjectScanTarget(projectName: string | undefined, projectRoot: string): string { const trimmedName = projectName?.trim() ?? '' @@ -125,6 +180,12 @@ export function normalizeProjectScanTarget(projectName: string | undefined, proj throw new InvalidUsageError('Project scan target could not be resolved from package-lock.json.') } +/** + * Parses an exact package key back into a resolved package identity. + * + * @param input Package key in `name@version` form. + * @returns Exact resolved package. + */ export function parsePackageKey(input: string): ResolvedPackage { const parsed = parsePackageSpec(input) @@ -138,6 +199,13 @@ export function parsePackageKey(input: string): ResolvedPackage { } } +/** + * Calculates package age in whole days. + * + * @param publishedAt Publish timestamp. + * @param now Comparison timestamp. + * @returns Non-negative whole-day age. + */ export function calculateAgeDays(publishedAt: string, now: Date = new Date()): number { const publishedTime = new Date(publishedAt).getTime() @@ -150,6 +218,12 @@ export function calculateAgeDays(publishedAt: string, now: Date = new Date()): n return Math.floor(diffMs / 86_400_000) } +/** + * Maps a numeric risk score to a risk level. + * + * @param score Score in the range `0..1`. + * @returns Risk level bucket. + */ export function riskLevelForScore(score: number): RiskLevel { if (score > 0.7) { return 'critical' @@ -162,6 +236,12 @@ export function riskLevelForScore(score: number): RiskLevel { return 'safe' } +/** + * Sums risk-signal weights into a bounded risk score. + * + * @param signals Signals contributing to a package assessment. + * @returns Score rounded to two decimal places. + */ export function riskScoreForSignals(signals: RiskSignal[]): number { const score = Math.min( 1, @@ -171,6 +251,12 @@ export function riskScoreForSignals(signals: RiskSignal[]): number { return Number(score.toFixed(2)) } +/** + * Maps a risk level to the default install recommendation. + * + * @param level Risk level bucket. + * @returns Recommendation for that level. + */ export function recommendationForRiskLevel(level: RiskLevel): Recommendation { switch (level) { case 'critical': @@ -182,6 +268,12 @@ export function recommendationForRiskLevel(level: RiskLevel): Recommendation { } } +/** + * Checks whether a deprecation message contains security-related language. + * + * @param message Deprecation message from registry metadata. + * @returns `true` when the message should be treated as security-related. + */ export function hasSecurityDeprecationLanguage(message: string | null): boolean { if (message === null) { return false diff --git a/src/interface/benchmark-renderer.ts b/src/interface/benchmark-renderer.ts index 2c39d60..9c2a45a 100644 --- a/src/interface/benchmark-renderer.ts +++ b/src/interface/benchmark-renderer.ts @@ -1,5 +1,11 @@ import type { BenchmarkResult, BenchmarkSuiteResult } from '../domain/benchmark.js' +/** + * Renders a benchmark suite result as aligned plain text. + * + * @param suiteResult Benchmark suite result to render. + * @returns Plain-text benchmark report. + */ export function renderBenchmarkSuite(suiteResult: BenchmarkSuiteResult): string { const idWidth = Math.max(...suiteResult.results.map((result) => result.id.length), 'ID'.length) const packageWidth = Math.max( diff --git a/src/interface/console-renderer.tsx b/src/interface/console-renderer.tsx index 86b8844..9988363 100644 --- a/src/interface/console-renderer.tsx +++ b/src/interface/console-renderer.tsx @@ -413,6 +413,12 @@ function riskLabel(level: RiskLevel): string { return formatPresentedRiskLevel(level) } +/** + * Formats a risk level for TUI display. + * + * @param level Risk level to format. + * @returns Lowercase label shown in the TUI. + */ export function formatPresentedRiskLevel(level: RiskLevel): string { switch (level) { case 'critical': @@ -460,10 +466,22 @@ function formatDownloads(downloads: number | null, isSecurityTombstone: boolean) return `${downloads.toLocaleString()} / week` } +/** + * Decides whether the TUI should render the overall-risk panel. + * + * @param result Partial scan result containing suspicious-count information. + * @returns `true` when overall risk should be shown. + */ export function shouldRenderOverallRisk(result: Pick): boolean { return result.suspicious_count > 0 } +/** + * Renders the interactive Ink scan UI. + * + * @param result Completed scan result. + * @returns Promise that resolves when the Ink app exits. + */ export async function renderInk(result: ScanResult): Promise { const app = render() await app.waitUntilExit() diff --git a/src/interface/evaluation-failure-renderer.ts b/src/interface/evaluation-failure-renderer.ts index f9741f7..d5aec9c 100644 --- a/src/interface/evaluation-failure-renderer.ts +++ b/src/interface/evaluation-failure-renderer.ts @@ -1,9 +1,21 @@ import type { FailureSurfacingSummary } from '../domain/failure-surfacing.js' +/** + * Renders failure-surfacing output as deterministic JSON. + * + * @param summary Failure surfacing summary to render. + * @returns JSON representation of the summary. + */ export function renderFailureSurfacingJson(summary: FailureSurfacingSummary): string { return JSON.stringify(summary, null, 2) } +/** + * Renders failure-surfacing output as deterministic plain text. + * + * @param summary Failure surfacing summary to render. + * @returns Plain-text failure surfacing report. + */ export function renderFailureSurfacingPlainText(summary: FailureSurfacingSummary): string { const lines = [ `Total scans: ${summary.total_records_scanned}`, diff --git a/src/interface/evaluation-renderer.ts b/src/interface/evaluation-renderer.ts index ebddfe3..e4733cc 100644 --- a/src/interface/evaluation-renderer.ts +++ b/src/interface/evaluation-renderer.ts @@ -1,9 +1,21 @@ import type { EvaluationSummary } from '../domain/contracts.js' +/** + * Renders evaluation output as deterministic JSON. + * + * @param summary Evaluation summary to render. + * @returns JSON representation of the summary. + */ export function renderEvaluationJson(summary: EvaluationSummary): string { return JSON.stringify(summary, null, 2) } +/** + * Renders evaluation output as deterministic plain text. + * + * @param summary Evaluation summary to render. + * @returns Plain-text evaluation report. + */ export function renderEvaluationPlainText(summary: EvaluationSummary): string { const lines = [ `Total scans: ${summary.total_scans}`, diff --git a/src/interface/field-reliability-summary.ts b/src/interface/field-reliability-summary.ts index 659bbf2..956697d 100644 --- a/src/interface/field-reliability-summary.ts +++ b/src/interface/field-reliability-summary.ts @@ -1,5 +1,11 @@ import type { ScanResult } from '../domain/entities.js' +/** + * Produces concise human-readable ADR-012 field reliability notes for scan output. + * + * @param result Completed scan result with field reliability metadata. + * @returns Summary lines for plain-text rendering. + */ export function getFieldReliabilityPolicySummary(result: ScanResult): string[] { const fields = result.field_reliability.fields diff --git a/src/interface/json-renderer.ts b/src/interface/json-renderer.ts index a6074b2..858ad28 100644 --- a/src/interface/json-renderer.ts +++ b/src/interface/json-renderer.ts @@ -1,5 +1,11 @@ import type { ScanResult } from '../domain/entities.js' +/** + * Renders the public scan contract as deterministic JSON. + * + * @param result Completed scan result. + * @returns Public JSON output without internal reliability metadata. + */ export function renderJson(result: ScanResult): string { const { field_reliability: _fieldReliability, ...publicResult } = result diff --git a/src/interface/review-renderer.ts b/src/interface/review-renderer.ts index 255255b..ba53701 100644 --- a/src/interface/review-renderer.ts +++ b/src/interface/review-renderer.ts @@ -1,9 +1,21 @@ import type { ReviewEvent } from '../domain/contracts.js' +/** + * Renders a review event as deterministic JSON. + * + * @param event Review event to render. + * @returns JSON representation of the review event. + */ export function renderReviewJson(event: ReviewEvent): string { return JSON.stringify(event, null, 2) } +/** + * Renders a review event as deterministic plain text. + * + * @param event Review event to render. + * @returns Human-readable review event output. + */ export function renderReviewPlainText(event: ReviewEvent): string { const lines = [ `Review event appended: ${event.record_id}`, diff --git a/src/interface/scan-output-presenter.ts b/src/interface/scan-output-presenter.ts index 7d7ae03..60d2869 100644 --- a/src/interface/scan-output-presenter.ts +++ b/src/interface/scan-output-presenter.ts @@ -18,12 +18,14 @@ const SECURITY_SIGNAL_TYPES = new Set([ const SECURITY_MESSAGE_PATTERN = /\b(?:security|vulnerab(?:ility|ilities)|cve-\d{4}-\d+)\b/i const CVE_PATTERN = /\bCVE-\d{4}-\d+\b/i +/** Standard scan summary counts used by human-facing renderers. */ export interface ScanSummaryBlock { packages_requiring_review: number security_related_findings: number packages_appearing_safe: number } +/** Compact scan summary projection for minimal output modes. */ export interface CompactScanSummary { scanned_package: string overall_risk_level: ScanResult['overall_risk_level'] diff --git a/test/evaluate-scans.test.ts b/test/evaluate-scans.test.ts index 73b6189..2e7d965 100644 --- a/test/evaluate-scans.test.ts +++ b/test/evaluate-scans.test.ts @@ -88,6 +88,7 @@ test('evaluate scans reports metadata coverage and latest-label counts', async ( 0, ) assert.equal(summary.field_readiness_issues.records_missing_field_reliability_count, 0) + assert.equal(summary.field_readiness_issues.has_advisories_placeholder_count, 2) assert.equal(summary.integrity_signals.synthetic_project_root_count, 0) assert.equal(summary.heuristic_output_presence.nodes_with_risk_score, 2) assert.equal(summary.export_readiness.records_total, 1) @@ -170,8 +171,8 @@ test('evaluate scans handles mixed historical records and readiness exclusion pr assert.equal(summary.integrity_signals.synthetic_project_root_count, 1) assert.equal(summary.integrity_signals.unresolved_registry_lookup_count, 1) assert.equal(summary.integrity_signals.deprecated_with_security_signal_count, 1) - assert.equal(summary.field_readiness_issues.dependents_count_unavailable_count, 1) - assert.equal(summary.field_readiness_issues.has_advisories_placeholder_count, 3) + assert.equal(summary.field_readiness_issues.dependents_count_unavailable_count, 2) + assert.equal(summary.field_readiness_issues.has_advisories_placeholder_count, 2) assert.equal(summary.heuristic_output_presence.nodes_with_risk_score, 3) assert.equal(summary.heuristic_output_presence.nodes_with_risk_level, 3) assert.equal(summary.heuristic_output_presence.nodes_with_recommendation, 3) diff --git a/test/metadata-field-state.test.ts b/test/metadata-field-state.test.ts new file mode 100644 index 0000000..19d5b5d --- /dev/null +++ b/test/metadata-field-state.test.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + getPackageNodeMetadataFieldState, + isObservedMetadataField, + notApplicableMetadataFieldState, + observedAbsentMetadataFieldState, + observedPresentMetadataFieldState, + unavailableMetadataFieldState, +} from '../src/domain/metadata-field-state.js' +import type { PackageNode } from '../src/domain/entities.js' + +test('metadata field state treats dependents_count null as unavailable instead of observed data', () => { + const state = getPackageNodeMetadataFieldState(createNode(), 'dependents_count') + + assert.deepEqual(state, unavailableMetadataFieldState('not_collected_yet')) + assert.equal(isObservedMetadataField(state), false) +}) + +test('metadata field state preserves genuinely observed dependents_count values', () => { + const state = getPackageNodeMetadataFieldState( + createNode({ + dependents_count: 42, + }), + 'dependents_count', + ) + + assert.deepEqual(state, observedPresentMetadataFieldState(42)) + assert.equal(isObservedMetadataField(state), true) +}) + +test('metadata field state treats has_advisories false as unavailable until advisories are collected', () => { + const state = getPackageNodeMetadataFieldState(createNode(), 'has_advisories') + + assert.deepEqual(state, unavailableMetadataFieldState('not_collected_yet')) + assert.equal(isObservedMetadataField(state), false) +}) + +test('metadata field state preserves genuinely observed advisory evidence when present', () => { + const state = getPackageNodeMetadataFieldState( + createNode({ + has_advisories: true, + }), + 'has_advisories', + ) + + assert.deepEqual(state, observedPresentMetadataFieldState(true)) + assert.equal(isObservedMetadataField(state), true) +}) + +test('metadata field state marks synthetic project roots as not applicable', () => { + const node = createNode({ + is_project_root: true, + metadata_status: 'synthetic_project_root', + }) + + assert.deepEqual( + getPackageNodeMetadataFieldState(node, 'dependents_count'), + notApplicableMetadataFieldState(), + ) + assert.deepEqual( + getPackageNodeMetadataFieldState(node, 'has_advisories'), + notApplicableMetadataFieldState(), + ) +}) + +test('metadata field state marks unresolved registry metadata as unavailable for ambiguous fields', () => { + const node = createNode({ + metadata_status: 'unresolved_registry_lookup', + }) + + assert.deepEqual( + getPackageNodeMetadataFieldState(node, 'dependents_count'), + unavailableMetadataFieldState('registry_metadata_unavailable'), + ) + assert.deepEqual( + getPackageNodeMetadataFieldState(node, 'has_advisories'), + unavailableMetadataFieldState('registry_metadata_unavailable'), + ) +}) + +test('metadata field state contract distinguishes observed absent from unavailable', () => { + const observedAbsent = observedAbsentMetadataFieldState(false) + const unavailable = unavailableMetadataFieldState('not_collected_yet') + + assert.equal(isObservedMetadataField(observedAbsent), true) + assert.equal(isObservedMetadataField(unavailable), false) + assert.equal(observedAbsent.observation, 'observed_absent') + assert.equal(unavailable.observation, 'unavailable') +}) + +function createNode(overrides: Partial = {}): PackageNode { + return { + name: 'pkg', + version: '1.0.0', + key: 'pkg@1.0.0', + depth: 0, + is_project_root: false, + metadata_status: 'enriched', + metadata_warning: null, + lockfile_resolved_url: null, + lockfile_integrity: null, + age_days: 10, + weekly_downloads: 1000, + dependents_count: null, + deprecated_message: null, + is_security_tombstone: false, + published_at: '2026-04-01T00:00:00.000Z', + first_published: '2026-04-01T00:00:00.000Z', + last_published: '2026-04-01T00:00:00.000Z', + total_versions: 2, + dependency_count: 0, + publish_events_last_30_days: 0, + has_advisories: false, + risk_score: 0, + risk_level: 'safe', + signals: [], + recommendation: 'install', + dependencies: [], + ...overrides, + } +}