diff --git a/src/core/package-detector.ts b/src/core/package-detector.ts index 479a9f6..7959524 100644 --- a/src/core/package-detector.ts +++ b/src/core/package-detector.ts @@ -26,6 +26,7 @@ interface PreparedDependencies { allDependencies: DependencyEntry[] uniquePackages: string[] currentVersions: Map + ignoredDependencies: DependencyEntry[] } export class PackageDetector { @@ -87,6 +88,7 @@ export class PackageDetector { allDependencies: prepared.allDependencies, uniquePackages: prepared.uniquePackages, currentVersions: prepared.currentVersions, + ignoredDependencies: prepared.ignoredDependencies, progress: this.createProgressSnapshot(prepared.uniquePackages.length, 0, 0, true), } @@ -223,6 +225,7 @@ export class PackageDetector { const tFilter = Date.now() const uniquePackageNames = new Set() const allDependencies: DependencyEntry[] = [] + const ignoredDependencies: DependencyEntry[] = [] let ignoredCount = 0 const seenWorkspaceRefs = new Set() const seenIgnored = new Set() @@ -239,6 +242,12 @@ export class PackageDetector { if (this.ignorePackages.length > 0 && isPackageIgnored(dep.name, this.ignorePackages)) { ignoredCount++ + ignoredDependencies.push({ + name: dep.name, + version: dep.version, + type: dep.type as DependencyEntry['type'], + packageJsonPath: dep.packageJsonPath, + }) if (!seenIgnored.has(dep.name)) { seenIgnored.add(dep.name) debugLog.info('PackageDetector', `ignoring package: ${dep.name}`) @@ -289,6 +298,7 @@ export class PackageDetector { allDependencies, uniquePackages, currentVersions, + ignoredDependencies, } } diff --git a/src/core/upgrade-runner.ts b/src/core/upgrade-runner.ts index bd0f064..f65d16e 100644 --- a/src/core/upgrade-runner.ts +++ b/src/core/upgrade-runner.ts @@ -72,7 +72,11 @@ export class UpgradeRunner { progress.failed = event.payload.progress.failed progress.isLoading = event.payload.progress.isLoading - selectionStates = [] + // Seed with display-only ignored rows (grayed out, never fetched + // or selected) so they appear inline from the first render. + selectionStates = this.ui.createIgnoredSelectionStates( + event.payload.ignoredDependencies + ) this.ui .selectPackagesToUpgradeProgressive(selectionStates, progress, (refresh) => { diff --git a/src/interactive-ui.ts b/src/interactive-ui.ts index 7d6d36b..31105c0 100644 --- a/src/interactive-ui.ts +++ b/src/interactive-ui.ts @@ -1,5 +1,6 @@ import { Key } from 'node:readline' import { + DependencyEntry, PackageLoadProgress, PackageInfo, PackageUpgradeChoice, @@ -18,7 +19,9 @@ import { PackageInfoModalController, VulnerabilityAuditController } from './ui/c import { createSelectionStates, createPendingSelectionStates, + createIgnoredSelectionStates, createUpgradeChoices, + comparePackageNames, runInteractiveSession, } from './ui/session' @@ -111,6 +114,10 @@ export class InteractiveUI { ) } + public createIgnoredSelectionStates(ignoredDeps: DependencyEntry[]): PackageSelectionState[] { + return createIgnoredSelectionStates(ignoredDeps) + } + public appendOutdatedBatchToSelectionStates( selectionStates: PackageSelectionState[], batch: StreamOutdatedPackagesBatchItem[], @@ -130,14 +137,23 @@ export class InteractiveUI { selectionStates.map((state) => `${state.name}@${state.currentVersionSpecifier}@${state.type}`) ) + let added = false outdatedStates.forEach((state) => { const key = `${state.name}@${state.currentVersionSpecifier}@${state.type}` if (!seen.has(key)) { selectionStates.push(state) seen.add(key) + added = true } }) + // Streamed batches arrive in fetch order; re-sort so they interleave + // alphabetically with the ignored rows seeded up front (Array.sort is + // stable, so equal-name duplicates keep their relative order). + if (added) { + selectionStates.sort((a, b) => comparePackageNames(a.name, b.name)) + } + this.enqueueSecurityAudit(selectionStates) } diff --git a/src/types/domain.ts b/src/types/domain.ts index 702a5ec..fd29021 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -49,7 +49,7 @@ export interface DependencyEntry { packageJsonPath: string } -export type PackageLoadState = 'pending' | 'ready' | 'failed' +export type PackageLoadState = 'pending' | 'ready' | 'failed' | 'ignored' export interface PackageUpgradeChoice { name: string diff --git a/src/types/streaming.ts b/src/types/streaming.ts index 8cafa23..5a851fd 100644 --- a/src/types/streaming.ts +++ b/src/types/streaming.ts @@ -19,6 +19,7 @@ export interface StreamOutdatedPackagesInitialPayload { allDependencies: DependencyEntry[] uniquePackages: string[] currentVersions: Map + ignoredDependencies: DependencyEntry[] progress: PackageLoadProgress } diff --git a/src/ui/controllers/vulnerability-audit-controller.ts b/src/ui/controllers/vulnerability-audit-controller.ts index 44f96a2..6d13c91 100644 --- a/src/ui/controllers/vulnerability-audit-controller.ts +++ b/src/ui/controllers/vulnerability-audit-controller.ts @@ -20,10 +20,12 @@ export class VulnerabilityAuditController { } enqueueStates(selectionStates: PackageSelectionState[], onUpdate?: () => void): void { - const packages = selectionStates.map((state) => ({ - name: state.name, - version: state.currentVersionSpecifier, - })) + const packages = selectionStates + .filter((state) => state.loadState !== 'ignored') + .map((state) => ({ + name: state.name, + version: state.currentVersionSpecifier, + })) const added = this.tracker.enqueue(packages) if (added > 0) { diff --git a/src/ui/renderer/package-list/interface.ts b/src/ui/renderer/package-list/interface.ts index e6906f9..1a1121b 100644 --- a/src/ui/renderer/package-list/interface.ts +++ b/src/ui/renderer/package-list/interface.ts @@ -145,17 +145,22 @@ export function renderInterface( statusLine = matchCount + ' ' + chalk.bold.white('Esc ') + chalk.gray('Clear filter') } else { + const ignoredCount = states.filter((state) => state.loadState === 'ignored').length + const ignoredSuffix = + ignoredCount > 0 ? chalk.gray(` (${chalk.white(ignoredCount)} ignored)`) : '' if (totalVisualItems > maxVisibleItems) { statusLine = chalk.gray( `Showing ${chalk.white(startItem)}-${chalk.white(endItem)} of ${chalk.white(totalPackages)} packages` ) + + ignoredSuffix + ' ' + chalk.bold.white('Enter ') + chalk.gray('Confirm') } else { statusLine = chalk.gray(`Showing all ${chalk.white(totalPackages)} packages`) + + ignoredSuffix + ' ' + chalk.bold.white('Enter ') + chalk.gray('Confirm') diff --git a/src/ui/renderer/package-list/rows.ts b/src/ui/renderer/package-list/rows.ts index 0de9e96..9a7898d 100644 --- a/src/ui/renderer/package-list/rows.ts +++ b/src/ui/renderer/package-list/rows.ts @@ -36,10 +36,15 @@ export function renderPackageLine( terminalWidth: number = 80, options: PackageListRenderOptions = {} ): string { - const prefix = isCurrentRow ? getThemeColor('success')('❯ ') : ' ' + const isIgnored = state.loadState === 'ignored' + // Ignored rows are never the cursor target (navigation skips them), but guard + // the caret defensively so it never appears on a disabled row. + const prefix = isCurrentRow && !isIgnored ? getThemeColor('success')('❯ ') : ' ' let packageName - if (state.name.startsWith('@')) { + if (isIgnored) { + packageName = chalk.gray(state.name) + } else if (state.name.startsWith('@')) { const parts = state.name.split('/') if (parts.length >= 2) { const author = parts[0] @@ -67,12 +72,21 @@ export function renderPackageLine( const isPending = state.loadState === 'pending' const isFailed = state.loadState === 'failed' - const currentDot = isCurrentSelected ? getThemeColor('dot')('●') : getThemeColor('dotEmpty')('○') - const currentVersion = chalk.white(state.currentVersionSpecifier) + const currentDot = isIgnored + ? getThemeColor('dotEmpty')('○') + : isCurrentSelected + ? getThemeColor('dot')('●') + : getThemeColor('dotEmpty')('○') + const currentVersion = isIgnored + ? chalk.gray(state.currentVersionSpecifier) + : chalk.white(state.currentVersionSpecifier) let rangeDot = '' let rangeVersionText = '' - if (isPending) { + if (isIgnored) { + rangeDot = '' + rangeVersionText = chalk.gray('(ignored)') + } else if (isPending) { rangeDot = getThemeColor('dotEmpty')('◌') rangeVersionText = chalk.gray('loading') } else if (isFailed) { @@ -136,11 +150,13 @@ export function renderPackageLine( const displayName = truncatedName !== state.name ? truncatedName : packageName const typeBadge = getTypeBadge(state.type) - const shouldShowVulnerability = shouldDisplayVulnerabilityForDependency(state.type, options) + // Ignored rows carry no vuln/health data and are display-only; suppress badges. + const shouldShowVulnerability = + !isIgnored && shouldDisplayVulnerabilityForDependency(state.type, options) const vulnBadge = shouldShowVulnerability ? getVulnerabilityBadge(state.vulnerability) : '' const vulnBadgeWidth = vulnBadge ? VersionUtils.getVisualLength(vulnBadge) + 1 : 0 // Deprecation / engines-incompatibility marker (independent of dep type). - const healthBadge = getHealthBadge(state) + const healthBadge = isIgnored ? '' : getHealthBadge(state) const healthBadgeWidth = healthBadge ? VersionUtils.getVisualLength(healthBadge) + 1 : 0 const nameLength = VersionUtils.getVisualLength(truncatedName) const namePadding = Math.max( @@ -157,7 +173,7 @@ export function renderPackageLine( ? `${displayName} ${nameDashes}${vulnSuffix}${healthSuffix}${typeBadge}` : `${displayName} ${nameDashes}${vulnSuffix}${healthSuffix}` - const currentSection = `${currentDot} ${currentVersion}` + const currentSection = isIgnored ? ` ${currentVersion}` : `${currentDot} ${currentVersion}` const currentSectionLength = VersionUtils.getVisualLength(currentSection) + 1 const currentPadding = Math.max(0, currentColumnWidth - currentSectionLength) const currentPaddingText = shouldShowDashes(currentPadding) @@ -166,8 +182,8 @@ export function renderPackageLine( const currentWithPadding = currentSection + ' ' + currentPaddingText let rangeSection = '' - if (isPending || isFailed || state.hasRangeUpdate) { - rangeSection = `${rangeDot} ${rangeVersionText}` + if (isIgnored || isPending || isFailed || state.hasRangeUpdate) { + rangeSection = rangeDot ? `${rangeDot} ${rangeVersionText}` : rangeVersionText const rangeSectionLength = VersionUtils.getVisualLength(rangeSection) + 1 const rangePadding = Math.max(0, rangeColumnWidth - rangeSectionLength) const rangePaddingText = shouldShowDashes(rangePadding) diff --git a/src/ui/session/action-dispatcher.ts b/src/ui/session/action-dispatcher.ts index b26e67d..ba5260c 100644 --- a/src/ui/session/action-dispatcher.ts +++ b/src/ui/session/action-dispatcher.ts @@ -73,16 +73,16 @@ export function dispatchAction(action: InputAction, ctx: DispatchContext): void switch (action.type) { case 'navigate_up': - stateManager.navigateUp(filteredStates.length) + stateManager.navigateUp(filteredStates) break case 'navigate_down': - stateManager.navigateDown(filteredStates.length) + stateManager.navigateDown(filteredStates) break case 'navigate_top': - stateManager.navigateTop(filteredStates.length) + stateManager.navigateTop(filteredStates) break case 'navigate_bottom': - stateManager.navigateBottom(filteredStates.length) + stateManager.navigateBottom(filteredStates) break case 'select_left': stateManager.updateSelection(filteredStates, 'left') diff --git a/src/ui/session/index.ts b/src/ui/session/index.ts index cca29b0..117edd3 100644 --- a/src/ui/session/index.ts +++ b/src/ui/session/index.ts @@ -2,8 +2,10 @@ export { runInteractiveSession } from './interactive-session' export { createSelectionStates, createPendingSelectionStates, + createIgnoredSelectionStates, createUpgradeChoices, deduplicatePackages, + comparePackageNames, } from './selection-state-builder' export { dispatchAction } from './action-dispatcher' export type { DispatchContext } from './action-dispatcher' diff --git a/src/ui/session/interactive-session.ts b/src/ui/session/interactive-session.ts index 9f4920a..0348ec1 100644 --- a/src/ui/session/interactive-session.ts +++ b/src/ui/session/interactive-session.ts @@ -149,8 +149,12 @@ export async function runInteractiveSession( } const renderInterface = () => { - const uiState = stateManager.getUIState() const filteredStates = stateManager.getFilteredStates(states, vulnerabilityDisplayOptions) + // Keep the cursor off display-only ignored rows. Self-heals on the first + // render and after streamed batches re-sort the list around seeded rows. + // Must run before getUIState() so the render uses the corrected row. + stateManager.ensureCursorOnNavigable(filteredStates) + const uiState = stateManager.getUIState() const auditProgress = vulnerabilityAuditController.getProgress() const bgCode = getTerminalBgColorCode() diff --git a/src/ui/session/selection-state-builder.ts b/src/ui/session/selection-state-builder.ts index 9b237e8..b1e65f3 100644 --- a/src/ui/session/selection-state-builder.ts +++ b/src/ui/session/selection-state-builder.ts @@ -1,5 +1,6 @@ import * as semver from 'semver' import { + DependencyEntry, PackageInfo, PackageSelectionState, PackageUpgradeChoice, @@ -7,6 +8,19 @@ import { } from '../../types' import { applyVersionPrefix } from '../utils' +/** + * Shared ordering for package lists: scoped packages (@…) first, then + * alphabetical. Used by deduplicatePackages and by the append-sort that keeps + * streamed-in outdated rows interleaved with seeded ignored rows. + */ +export function comparePackageNames(a: string, b: string): number { + const aIsScoped = a.startsWith('@') + const bIsScoped = b.startsWith('@') + if (aIsScoped && !bIsScoped) return -1 + if (!aIsScoped && bIsScoped) return 1 + return a.localeCompare(b) +} + type CachedSummaryFn = ( name: string, version: string, @@ -31,13 +45,9 @@ export function deduplicatePackages( } return new Map( - Array.from(uniquePackages.entries()).sort(([, a], [, b]) => { - const aIsScoped = a.pkg.name.startsWith('@') - const bIsScoped = b.pkg.name.startsWith('@') - if (aIsScoped && !bIsScoped) return -1 - if (!aIsScoped && bIsScoped) return 1 - return a.pkg.name.localeCompare(b.pkg.name) - }) + Array.from(uniquePackages.entries()).sort(([, a], [, b]) => + comparePackageNames(a.pkg.name, b.pkg.name) + ) ) } @@ -117,6 +127,49 @@ export function createPendingSelectionStates( }) } +/** + * Build display-only states for packages matched by the `.inuprc` ignore list. + * These are rendered grayed-out and are never fetched, selected, or upgraded — + * the `loadState: 'ignored'` value gates them out of every selection guard and + * out of createUpgradeChoices (which requires loadState === 'ready'). + */ +export function createIgnoredSelectionStates( + ignoredDeps: DependencyEntry[] +): PackageSelectionState[] { + const uniquePackages = deduplicatePackages( + ignoredDeps.map((dep) => ({ + name: dep.name, + currentVersion: dep.version, + rangeVersion: dep.version, + latestVersion: dep.version, + type: dep.type, + packageJsonPath: dep.packageJsonPath, + isOutdated: false, + hasRangeUpdate: false, + hasMajorUpdate: false, + })) + ) + + return Array.from(uniquePackages.values()).map(({ pkg, packageJsonPaths }) => { + const currentClean = semver.coerce(pkg.currentVersion)?.version || pkg.currentVersion + + return { + name: pkg.name, + packageJsonPath: pkg.packageJsonPath, + packageJsonPaths: Array.from(packageJsonPaths), + currentVersionSpecifier: pkg.currentVersion, + currentVersion: currentClean, + rangeVersion: currentClean, + latestVersion: currentClean, + selectedOption: 'none', + loadState: 'ignored', + hasRangeUpdate: false, + hasMajorUpdate: false, + type: pkg.type, + } + }) +} + export function createUpgradeChoices( selectedStates: PackageSelectionState[], saveExact: boolean = false diff --git a/src/ui/state/navigation-manager.ts b/src/ui/state/navigation-manager.ts index a8b100a..6d7d36a 100644 --- a/src/ui/state/navigation-manager.ts +++ b/src/ui/state/navigation-manager.ts @@ -1,4 +1,9 @@ -import { RenderableItem } from '../../types' +import { PackageSelectionState, RenderableItem } from '../../types' + +/** A row is navigable unless it's a display-only ignored package. */ +function isNavigable(state: PackageSelectionState | undefined): boolean { + return !!state && state.loadState !== 'ignored' +} export interface NavigationState { currentRow: number // Index into states array (package index) @@ -73,26 +78,34 @@ export class NavigationManager { return 0 } - // Find the next navigable package index in the given direction + // Find the next navigable package index in the given direction, skipping + // non-navigable (ignored) rows. private findNextPackageIndex( currentPackageIndex: number, direction: 'up' | 'down', - totalPackages: number + states: PackageSelectionState[] ): number { + const totalPackages = states.length + if (this.renderableItems.length === 0) { - // Fallback to simple navigation if no renderable items - if (direction === 'up') { - return currentPackageIndex <= 0 ? totalPackages - 1 : currentPackageIndex - 1 - } else { - return currentPackageIndex >= totalPackages - 1 ? 0 : currentPackageIndex + 1 + // Flat mode: step in the given direction with wrap-around, skipping + // ignored rows. Bounded by totalPackages so an all-ignored list (no + // navigable target) leaves the cursor put instead of looping forever. + if (totalPackages === 0) return currentPackageIndex + const step = direction === 'up' ? -1 : 1 + let index = currentPackageIndex + for (let i = 0; i < totalPackages; i++) { + index = (index + step + totalPackages) % totalPackages + if (isNavigable(states[index])) return index } + return currentPackageIndex } - // Get all package items with their visual indices + // Grouped mode (currently unused): collect navigable package items. const packageItems: { visualIndex: number; packageIndex: number }[] = [] for (let i = 0; i < this.renderableItems.length; i++) { const item = this.renderableItems[i] - if (item.type === 'package') { + if (item.type === 'package' && isNavigable(item.state)) { packageItems.push({ visualIndex: i, packageIndex: item.originalIndex }) } } @@ -113,47 +126,88 @@ export class NavigationManager { } } - navigateUp(totalItems: number): void { + navigateUp(states: PackageSelectionState[]): void { + const totalItems = states.length if (totalItems === 0) return this.state.previousRow = this.state.currentRow - this.state.currentRow = this.findNextPackageIndex(this.state.currentRow, 'up', totalItems) + this.state.currentRow = this.findNextPackageIndex(this.state.currentRow, 'up', states) this.ensureVisible(this.state.currentRow, totalItems) } - navigateDown(totalItems: number): void { + navigateDown(states: PackageSelectionState[]): void { + const totalItems = states.length if (totalItems === 0) return this.state.previousRow = this.state.currentRow - this.state.currentRow = this.findNextPackageIndex(this.state.currentRow, 'down', totalItems) + this.state.currentRow = this.findNextPackageIndex(this.state.currentRow, 'down', states) this.ensureVisible(this.state.currentRow, totalItems) } - navigateTop(totalItems: number): void { + navigateTop(states: PackageSelectionState[]): void { + const totalItems = states.length if (totalItems === 0) return this.state.previousRow = this.state.currentRow - this.state.currentRow = this.firstPackageIndex() + this.state.currentRow = this.firstPackageIndex(states) this.ensureVisible(this.state.currentRow, totalItems) } - navigateBottom(totalItems: number): void { + navigateBottom(states: PackageSelectionState[]): void { + const totalItems = states.length if (totalItems === 0) return this.state.previousRow = this.state.currentRow - this.state.currentRow = this.lastPackageIndex(totalItems) + this.state.currentRow = this.lastPackageIndex(states) this.ensureVisible(this.state.currentRow, totalItems) } - private firstPackageIndex(): number { - if (this.renderableItems.length === 0) return 0 - const first = this.renderableItems.find((item) => item.type === 'package') - return first ? first.originalIndex : 0 + // Move the cursor onto the nearest navigable row if it currently sits on an + // ignored one. Searches forward first, then backward. + // When no navigable row exists yet (e.g. during initial load with only ignored + // rows seeded), sets currentRow to states.length so the renderer shows no + // highlighted row at all — it will snap into place on the next render once a + // navigable row arrives. + ensureCursorOnNavigable(states: PackageSelectionState[]): void { + if (states.length === 0) return + if (isNavigable(states[this.state.currentRow])) return + const forward = states.findIndex( + (state, i) => i >= this.state.currentRow && isNavigable(state) + ) + if (forward !== -1) { + this.state.currentRow = forward + this.ensureVisible(this.state.currentRow, states.length) + } else { + const firstNavigable = states.findIndex((state) => isNavigable(state)) + if (firstNavigable !== -1) { + this.state.currentRow = firstNavigable + this.ensureVisible(this.state.currentRow, states.length) + } else { + // No navigable rows yet — park the cursor off-screen so nothing is highlighted. + this.state.currentRow = states.length + } + } } - private lastPackageIndex(totalPackages: number): number { - if (this.renderableItems.length === 0) return totalPackages - 1 + private firstPackageIndex(states: PackageSelectionState[]): number { + if (this.renderableItems.length === 0) { + const idx = states.findIndex((state) => isNavigable(state)) + return idx === -1 ? 0 : idx + } + const first = this.renderableItems.find( + (item) => item.type === 'package' && isNavigable(item.state) + ) + return first && first.type === 'package' ? first.originalIndex : 0 + } + + private lastPackageIndex(states: PackageSelectionState[]): number { + if (this.renderableItems.length === 0) { + for (let i = states.length - 1; i >= 0; i--) { + if (isNavigable(states[i])) return i + } + return states.length - 1 + } for (let i = this.renderableItems.length - 1; i >= 0; i--) { const item = this.renderableItems[i] - if (item.type === 'package') return item.originalIndex + if (item.type === 'package' && isNavigable(item.state)) return item.originalIndex } - return totalPackages - 1 + return states.length - 1 } private ensureVisible(packageIndex: number, totalPackages: number): void { diff --git a/src/ui/state/state-manager.ts b/src/ui/state/state-manager.ts index 79a65dc..ebe1c0b 100644 --- a/src/ui/state/state-manager.ts +++ b/src/ui/state/state-manager.ts @@ -128,21 +128,30 @@ export class StateManager { this.navigationManager.setRenderableItems(items) } - // Navigation delegation - navigateUp(totalItems: number): void { - this.navigationManager.navigateUp(totalItems) + // Navigation delegation. The states array is passed (not just a count) so the + // navigation manager can skip non-navigable rows (e.g. ignored packages). + navigateUp(states: PackageSelectionState[]): void { + this.navigationManager.navigateUp(states) } - navigateDown(totalItems: number): void { - this.navigationManager.navigateDown(totalItems) + navigateDown(states: PackageSelectionState[]): void { + this.navigationManager.navigateDown(states) } - navigateTop(totalItems: number): void { - this.navigationManager.navigateTop(totalItems) + navigateTop(states: PackageSelectionState[]): void { + this.navigationManager.navigateTop(states) } - navigateBottom(totalItems: number): void { - this.navigationManager.navigateBottom(totalItems) + navigateBottom(states: PackageSelectionState[]): void { + this.navigationManager.navigateBottom(states) + } + + // Move the cursor off a non-navigable (ignored) row onto the nearest + // navigable one. Called once before the first render and after the + // append-sort re-orders the list. No-op if the cursor is already navigable + // or every row is ignored. + ensureCursorOnNavigable(states: PackageSelectionState[]): void { + this.navigationManager.ensureCursorOnNavigable(states) } packageIndexToVisualIndex(packageIndex: number): number { diff --git a/test/unit/core/package-detector.test.ts b/test/unit/core/package-detector.test.ts index a9e6aa0..a55cdf3 100644 --- a/test/unit/core/package-detector.test.ts +++ b/test/unit/core/package-detector.test.ts @@ -43,9 +43,11 @@ vi.mock('../../../src/ui/utils', () => ({ })) import { PackageDetector } from '../../../src/core/package-detector' +import { isPackageIgnored } from '../../../src/config' describe('PackageDetector streaming', () => { beforeEach(() => { + vi.mocked(isPackageIgnored).mockReturnValue(false) mocks.findPackageJson.mockReturnValue('/repo/package.json') mocks.readPackageJson.mockReturnValue({ name: 'fixture' }) mocks.findAllPackageJsonFilesAsync.mockResolvedValue(['/repo/package.json']) @@ -63,7 +65,9 @@ describe('PackageDetector streaming', () => { packageJsonPath: '/repo/package.json', }, ]) - mocks.findClosestMinorVersion.mockImplementation((version: string, versions: string[]) => versions[0] ?? version) + mocks.findClosestMinorVersion.mockImplementation( + (version: string, versions: string[]) => versions[0] ?? version + ) mocks.fetchPackageVersions.mockImplementation( async ( packageNames: string[], @@ -118,6 +122,7 @@ describe('PackageDetector streaming', () => { if (event.type === 'initial') { expect(event.payload.uniquePackages).toEqual(['@scope/pkg', 'zod']) + expect(event.payload.ignoredDependencies).toEqual([]) expect(event.payload.progress).toMatchObject({ total: 2, resolved: 0, @@ -163,4 +168,30 @@ describe('PackageDetector streaming', () => { expect(packages[0].name).toBe('@scope/pkg') expect(packages[1].name).toBe('zod') }) + + it('surfaces ignored deps in the initial payload without fetching them', async () => { + vi.mocked(isPackageIgnored).mockImplementation((name: string) => name === 'zod') + const fetched: string[] = [] + mocks.fetchPackageVersions.mockImplementation( + async (packageNames: string[], options: { onBatchReady: (batch: any[]) => void }) => { + fetched.push(...packageNames) + return new Map() + } + ) + + const detector = new PackageDetector({ cwd: '/repo', ignorePackages: ['zod'] }) + let ignoredNames: string[] = [] + let uniqueNames: string[] = [] + + await detector.streamOutdatedPackages((event) => { + if (event.type === 'initial') { + ignoredNames = event.payload.ignoredDependencies.map((dep) => dep.name) + uniqueNames = event.payload.uniquePackages + } + }) + + expect(ignoredNames).toEqual(['zod']) + expect(uniqueNames).toEqual(['@scope/pkg']) + expect(fetched).toEqual(['@scope/pkg']) // zod is never fetched + }) }) diff --git a/test/unit/core/upgrade-runner.test.ts b/test/unit/core/upgrade-runner.test.ts index 1a0e8af..b525c88 100644 --- a/test/unit/core/upgrade-runner.test.ts +++ b/test/unit/core/upgrade-runner.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ clearProgress: vi.fn(), detectPackageManager: vi.fn(), appendOutdatedBatchToSelectionStates: vi.fn(), + createIgnoredSelectionStates: vi.fn(() => []), })) vi.mock('../../../src/core/package-detector', () => ({ @@ -27,6 +28,7 @@ vi.mock('../../../src/interactive-ui', () => ({ selectPackagesToUpgrade = mocks.selectPackagesToUpgrade confirmUpgrade = mocks.confirmUpgrade appendOutdatedBatchToSelectionStates = mocks.appendOutdatedBatchToSelectionStates + createIgnoredSelectionStates = mocks.createIgnoredSelectionStates }, })) @@ -82,6 +84,7 @@ describe('UpgradeRunner terminal handoff', () => { allDependencies: [], uniquePackages: ['next'], currentVersions: new Map([['next', '^1.0.0']]), + ignoredDependencies: [], progress, }, }) @@ -114,8 +117,23 @@ describe('UpgradeRunner terminal handoff', () => { it('exits early with up-to-date message when no outdated packages', async () => { mocks.streamOutdatedPackages.mockImplementation(async (onEvent: any) => { - onEvent({ type: 'initial', payload: { allDependencies: [], uniquePackages: [], currentVersions: new Map(), progress: { discovered: 0, resolved: 0, total: 0, failed: 0, isLoading: true } } }) - onEvent({ type: 'complete', payload: { packages: [], progress: { discovered: 0, resolved: 0, total: 0, failed: 0, isLoading: false } } }) + onEvent({ + type: 'initial', + payload: { + allDependencies: [], + uniquePackages: [], + currentVersions: new Map(), + ignoredDependencies: [], + progress: { discovered: 0, resolved: 0, total: 0, failed: 0, isLoading: true }, + }, + }) + onEvent({ + type: 'complete', + payload: { + packages: [], + progress: { discovered: 0, resolved: 0, total: 0, failed: 0, isLoading: false }, + }, + }) }) mocks.getOutdatedPackagesOnly.mockReturnValue([]) mocks.selectPackagesToUpgradeProgressive.mockResolvedValue([]) @@ -138,10 +156,16 @@ describe('UpgradeRunner terminal handoff', () => { }) it('exits with "Upgrade cancelled" when user declines confirmation', async () => { - mocks.selectPackagesToUpgradeProgressive.mockResolvedValue([{ - name: 'next', packageJsonPath: '/repo/package.json', dependencyType: 'dependencies', - upgradeType: 'range', targetVersion: '^1.1.0', currentVersionSpecifier: '^1.0.0', - }]) + mocks.selectPackagesToUpgradeProgressive.mockResolvedValue([ + { + name: 'next', + packageJsonPath: '/repo/package.json', + dependencyType: 'dependencies', + upgradeType: 'range', + targetVersion: '^1.1.0', + currentVersionSpecifier: '^1.0.0', + }, + ]) mocks.confirmUpgrade.mockResolvedValue(false) const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) @@ -153,8 +177,12 @@ describe('UpgradeRunner terminal handoff', () => { it('calls upgradePackages when user confirms', async () => { const choice = { - name: 'next', packageJsonPath: '/repo/package.json', dependencyType: 'dependencies', - upgradeType: 'range', targetVersion: '^1.1.0', currentVersionSpecifier: '^1.0.0', + name: 'next', + packageJsonPath: '/repo/package.json', + dependencyType: 'dependencies', + upgradeType: 'range', + targetVersion: '^1.1.0', + currentVersionSpecifier: '^1.0.0', } mocks.selectPackagesToUpgradeProgressive.mockResolvedValue([choice]) mocks.confirmUpgrade.mockResolvedValue(true) @@ -191,8 +219,7 @@ describe('UpgradeRunner terminal handoff', () => { }, ] - mocks.selectPackagesToUpgradeProgressive - .mockResolvedValueOnce(selectedChoices) + mocks.selectPackagesToUpgradeProgressive.mockResolvedValueOnce(selectedChoices) mocks.selectPackagesToUpgrade.mockResolvedValueOnce(selectedChoices) mocks.confirmUpgrade.mockResolvedValueOnce(null).mockResolvedValueOnce(false) diff --git a/test/unit/ui/controllers/vulnerability-audit-controller.test.ts b/test/unit/ui/controllers/vulnerability-audit-controller.test.ts index 423a6ae..ab2f584 100644 --- a/test/unit/ui/controllers/vulnerability-audit-controller.test.ts +++ b/test/unit/ui/controllers/vulnerability-audit-controller.test.ts @@ -19,7 +19,11 @@ import { VulnerabilityAuditController } from '../../../../src/ui/controllers' import { makeSelectionState } from '../../../fixtures/selection-state-factory' const createState = (name: string) => - makeSelectionState({ name, packageJsonPath: `/repo/${name}/package.json`, packageJsonPaths: [`/repo/${name}/package.json`] }) + makeSelectionState({ + name, + packageJsonPath: `/repo/${name}/package.json`, + packageJsonPaths: [`/repo/${name}/package.json`], + }) describe('VulnerabilityAuditController', () => { beforeEach(() => { @@ -71,6 +75,17 @@ describe('VulnerabilityAuditController', () => { expect(refresh).toHaveBeenCalled() }) + it('does not enqueue ignored states (no network call)', async () => { + const controller = new VulnerabilityAuditController() + const ignored = makeSelectionState({ name: 'lodash', loadState: 'ignored' }) + + controller.enqueueStates([ignored]) + + // Nothing was queued, so no audit runs and fetch is never called. + expect(controller.getProgress().total).toBe(0) + expect(mocks.fetchVulnerabilities).not.toHaveBeenCalled() + }) + it('swallows fetch failures and leaves states untouched', async () => { mocks.fetchVulnerabilities.mockRejectedValue(new Error('network')) diff --git a/test/unit/ui/package-list.test.ts b/test/unit/ui/package-list.test.ts index a6fb3a6..474a1f1 100644 --- a/test/unit/ui/package-list.test.ts +++ b/test/unit/ui/package-list.test.ts @@ -42,6 +42,23 @@ describe('package-list renderer', () => { expect(line).toContain('unavailable') }) + it('renders ignored rows grayed with an (ignored) marker and no caret', () => { + const line = renderPackageLine( + { + ...baseState, + loadState: 'ignored', + hasRangeUpdate: false, + hasMajorUpdate: false, + }, + 0, + true, // even as the "current" row, an ignored row shows no caret + 120 + ) + + expect(line).toContain('(ignored)') + expect(line).not.toContain('❯') + }) + it('uses fixed-width vulnerability badges so rows stay aligned', () => { const highLine = renderPackageLine( { @@ -184,7 +201,20 @@ describe('package-list renderer', () => { }) it('pads rendered list rows to the terminal width', () => { - const lines = renderInterface([baseState], 0, 0, 10, false, [], 'Deps', undefined, false, '', 1, 120) + const lines = renderInterface( + [baseState], + 0, + 0, + 10, + false, + [], + 'Deps', + undefined, + false, + '', + 1, + 120 + ) expect(lines.every((line) => VersionUtils.getVisualLength(line) >= 120)).toBe(true) }) diff --git a/test/unit/ui/selection-state-builder.test.ts b/test/unit/ui/selection-state-builder.test.ts index f1c7033..ca9c460 100644 --- a/test/unit/ui/selection-state-builder.test.ts +++ b/test/unit/ui/selection-state-builder.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect } from 'vitest' -import { createUpgradeChoices } from '../../../src/ui/session/selection-state-builder' -import { PackageSelectionState } from '../../../src/types' +import { + createUpgradeChoices, + createIgnoredSelectionStates, +} from '../../../src/ui/session/selection-state-builder' +import { DependencyEntry, PackageSelectionState } from '../../../src/types' function makeState(overrides: Partial = {}): PackageSelectionState { return { @@ -51,4 +54,44 @@ describe('createUpgradeChoices', () => { expect(choices).toHaveLength(0) }) + + it('excludes ignored states even if somehow marked selected', () => { + const choices = createUpgradeChoices([ + makeState({ loadState: 'ignored', selectedOption: 'latest' }), + ]) + + expect(choices).toHaveLength(0) + }) +}) + +describe('createIgnoredSelectionStates', () => { + const dep = (over: Partial = {}): DependencyEntry => ({ + name: 'lodash', + version: '^4.17.0', + type: 'dependencies', + packageJsonPath: '/repo/package.json', + ...over, + }) + + it('builds display-only states with loadState ignored and no selection', () => { + const states = createIgnoredSelectionStates([dep()]) + + expect(states).toHaveLength(1) + expect(states[0].loadState).toBe('ignored') + expect(states[0].selectedOption).toBe('none') + expect(states[0].hasRangeUpdate).toBe(false) + expect(states[0].hasMajorUpdate).toBe(false) + expect(states[0].currentVersionSpecifier).toBe('^4.17.0') + }) + + it('dedupes and sorts scoped-first then alphabetical', () => { + const states = createIgnoredSelectionStates([ + dep({ name: 'zod' }), + dep({ name: 'lodash' }), + dep({ name: '@scope/pkg' }), + dep({ name: 'lodash' }), // duplicate name@version@type + ]) + + expect(states.map((s) => s.name)).toEqual(['@scope/pkg', 'lodash', 'zod']) + }) }) diff --git a/test/unit/ui/state-manager.test.ts b/test/unit/ui/state-manager.test.ts index 3dedf52..f2116fd 100644 --- a/test/unit/ui/state-manager.test.ts +++ b/test/unit/ui/state-manager.test.ts @@ -46,23 +46,87 @@ describe('StateManager.toggleSelection', () => { sm.toggleSelection(states) expect(states[0].selectedOption).toBe('none') }) + + it('ignores ignored rows for toggle, select, and bulk', () => { + const sm = new StateManager(0, 24) + const states = [ready({ loadState: 'ignored' })] + sm.toggleSelection(states) + sm.updateSelection(states, 'right') + sm.bulkSelectLatest(states) + sm.bulkSelectMinor(states) + expect(states[0].selectedOption).toBe('none') + }) }) +const fiveStates = (): PackageSelectionState[] => + Array.from({ length: 5 }, (_, i) => ready({ name: `pkg-${i}` })) + describe('StateManager navigation jumps', () => { it('navigateBottom selects the last package (flat mode)', () => { const sm = new StateManager(0, 24) - sm.navigateBottom(5) + sm.navigateBottom(fiveStates()) expect(sm.getUIState().currentRow).toBe(4) }) it('navigateTop returns to the first package', () => { const sm = new StateManager(0, 24) - sm.navigateBottom(5) - sm.navigateTop(5) + const states = fiveStates() + sm.navigateBottom(states) + sm.navigateTop(states) expect(sm.getUIState().currentRow).toBe(0) }) }) +describe('StateManager navigation skips ignored rows', () => { + // [ignored, ready, ignored, ready] + const mixed = (): PackageSelectionState[] => [ + ready({ name: 'a', loadState: 'ignored' }), + ready({ name: 'b' }), + ready({ name: 'c', loadState: 'ignored' }), + ready({ name: 'd' }), + ] + + it('navigateDown skips ignored rows', () => { + const sm = new StateManager(0, 24) + const states = mixed() + sm.ensureCursorOnNavigable(states) // start on first navigable (index 1) + expect(sm.getUIState().currentRow).toBe(1) + sm.navigateDown(states) + expect(sm.getUIState().currentRow).toBe(3) + sm.navigateDown(states) // wraps to first navigable + expect(sm.getUIState().currentRow).toBe(1) + }) + + it('navigateTop/Bottom land on first/last navigable', () => { + const sm = new StateManager(0, 24) + const states = mixed() + sm.navigateBottom(states) + expect(sm.getUIState().currentRow).toBe(3) + sm.navigateTop(states) + expect(sm.getUIState().currentRow).toBe(1) + }) + + it('ensureCursorOnNavigable moves off an ignored first row', () => { + const sm = new StateManager(0, 24) + sm.ensureCursorOnNavigable(mixed()) + expect(sm.getUIState().currentRow).toBe(1) + }) + + it('all-ignored list parks the cursor off-screen (no row highlighted)', () => { + const sm = new StateManager(0, 24) + const states = [ + ready({ name: 'a', loadState: 'ignored' }), + ready({ name: 'b', loadState: 'ignored' }), + ] + sm.ensureCursorOnNavigable(states) + // currentRow is set to states.length so no row index matches during render. + expect(sm.getUIState().currentRow).toBe(states.length) + // Navigating in an all-ignored list keeps cursor put. + sm.navigateDown(states) + expect(sm.getUIState().currentRow).toBe(states.length) + }) +}) + describe('StateManager notice', () => { it('stores and clears a transient notice', () => { const sm = new StateManager(0, 24)