/** * Module version drift check. * * For each installed module, compares the installed version to the * latest non-yanked version in the registry. A newer published version * produces a `drift` finding per module. Network/registry failures * for individual modules are surfaced as findings (severity: drift) * so the user knows their audit may be incomplete, but they don't * block the run. * * `registryFetcher` is injectable so tests don't hit a real registry. */ import { compareSemver } from './cli-version'; import type { DriftFinding } from './types'; export interface InstalledModule { id: string; version: string; } export interface RegistryVersionInfo { latest: string | null; /** Optional: count of intermediate releases between installed and latest, for the report. */ intermediateCount?: number; /** * Optional: release.json `message` from each release between installed * and latest, newest-first. Surfaced in the audit report so the user * sees "what changed" without leaving the terminal. */ intermediateMessages?: Array<{ version: string; message: string }>; } /** * Fetches the latest registry version for a module. Returns null in * the `.latest` field if the module isn't in the registry; throws on * a transport error so the caller can decide between "module not * published" and "lookup failed". */ export type ModuleVersionFetcher = (moduleId: string) => Promise; /** * Bucket-key for a rejection reason: a short string that identifies * the class of failure. Used to detect "every module failed for the * same reason" (e.g. registry unreachable) so we can collapse to one * aggregate finding instead of N noisy ones. */ function errorBucketKey(reason: unknown): string { if (reason instanceof Error) { if (reason.name === 'TimeoutError') return 'timeout'; if (reason.message.includes('ENOTFOUND') || reason.message.includes('EAI_AGAIN')) return 'dns'; if (reason.message.includes('ECONNREFUSED')) return 'connection-refused'; if (reason.message.includes('fetch failed')) return 'fetch-failed'; return reason.name; } return String(reason).slice(0, 40); } export interface ModuleVersionsAuditDeps { installed: InstalledModule[]; fetcher: ModuleVersionFetcher; } export async function auditModuleVersions(deps: ModuleVersionsAuditDeps): Promise { const findings: DriftFinding[] = []; // Run lookups in parallel; isolate per-module failures. const results = await Promise.allSettled( deps.installed.map(async (m) => ({ module: m, info: await deps.fetcher(m.id) })), ); // If every lookup failed with the same error class (e.g. the // registry is unreachable), emit ONE aggregate finding instead of // N identical per-module findings — that's the user-facing signal, // not "module X is broken". const rejected = results.filter((r) => r.status === 'rejected') as PromiseRejectedResult[]; if (rejected.length > 0 && rejected.length === results.length) { const errorBuckets = new Map(); for (const r of rejected) { const key = errorBucketKey(r.reason); errorBuckets.set(key, (errorBuckets.get(key) ?? 0) + 1); } if (errorBuckets.size === 1) { const [reason] = errorBuckets.keys(); findings.push({ category: 'module_versions', // Same fact as the per-module rejection below: the registry did not // answer, so nothing was measured. D7 severity, not drift — this // aggregate used to say "drift" and rendered the fleet DRIFT when // the truth was "I could not ask". severity: 'unmeasured', code: 'registry_unreachable', message: `Registry unreachable — ${rejected.length} module${rejected.length === 1 ? '' : 's'} unchecked (${reason})`, remediation: 'Check network connectivity to the registry, then press R to re-audit.', actionable: false, subject: 'system', }); return findings; } } for (let i = 0; i < results.length; i++) { const r = results[i]; const installed = deps.installed[i]; if (r.status === 'rejected') { findings.push({ category: 'module_versions', // A registry that did not answer is not a version difference. Reporting // it as `drift` said "this module is behind" when the truth was "I // could not ask" — the ambiguity D7 exists to remove. severity: 'unmeasured', code: 'module_version_lookup_failed', message: `${installed.id}: registry lookup failed (${String(r.reason).slice(0, 80)})`, remediation: 'Check network connectivity to the registry, then press R to re-audit.', actionable: false, subject: installed.id, }); continue; } const { module, info } = r.value; if (info.latest === null) { findings.push({ category: 'module_versions', severity: 'drift', code: 'module_not_in_registry', message: `${module.id}@${module.version}: not found in registry (private/local-only build?)`, subject: module.id, }); continue; } const cmp = compareSemver(module.version, info.latest); if (cmp >= 0) continue; // up to date or ahead const tail = info.intermediateCount && info.intermediateCount > 1 ? ` (${info.intermediateCount} intervening releases)` : ''; // Format release messages as `details` lines if present. const details = info.intermediateMessages && info.intermediateMessages.length > 0 ? info.intermediateMessages.map((m) => ` ↳ "${m.message}" (${m.version})`).join('\n') : undefined; findings.push({ category: 'module_versions', severity: 'drift', code: 'module_version_drift', message: `${module.id}: ${module.version} → ${info.latest}${tail}`, details, remediation: 'celilo system update', actionable: true, subject: module.id, }); } return findings; }