CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
depimpact.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 16b325c | 1 | /** |
| 2 | * Dependency Impact Analyzer | |
| 3 | * | |
| 4 | * GitHub: "There's a new version of lodash" | |
| 5 | * gluecron: "Upgrading lodash v4→v5 will break your auth.ts:47 | |
| 6 | * because _.get() was removed. Here's the fix." | |
| 7 | * | |
| 8 | * Analyzes import graphs, detects which functions from a dependency | |
| 9 | * your code actually uses, and predicts what breaks on upgrade. | |
| 10 | */ | |
| 11 | ||
| 12 | import { getRepoPath } from "../git/repository"; | |
| 13 | ||
| 14 | export interface DependencyMap { | |
| 15 | name: string; | |
| 16 | version: string; | |
| 17 | usedIn: ImportUsage[]; | |
| 18 | totalImports: number; | |
| 19 | isDevDep: boolean; | |
| 20 | } | |
| 21 | ||
| 22 | export interface ImportUsage { | |
| 23 | file: string; | |
| 24 | line: number; | |
| 25 | importedSymbols: string[]; | |
| 26 | importStatement: string; | |
| 27 | } | |
| 28 | ||
| 29 | export interface ImportGraph { | |
| 30 | files: Record<string, string[]>; // file -> files it imports | |
| 31 | dependencies: DependencyMap[]; | |
| 32 | internalModules: number; | |
| 33 | externalDependencies: number; | |
| 34 | circularDeps: string[][]; | |
| 35 | } | |
| 36 | ||
| 37 | async function exec( | |
| 38 | cmd: string[], | |
| 39 | cwd: string | |
| 40 | ): Promise<string> { | |
| 41 | const proc = Bun.spawn(cmd, { | |
| 42 | cwd, | |
| 43 | stdout: "pipe", | |
| 44 | stderr: "pipe", | |
| 45 | }); | |
| 46 | const stdout = await new Response(proc.stdout).text(); | |
| 47 | await proc.exited; | |
| 48 | return stdout.trim(); | |
| 49 | } | |
| 50 | ||
| 51 | /** | |
| 52 | * Build the complete import graph for a repository. | |
| 53 | * Maps every file to its imports, both internal and external. | |
| 54 | */ | |
| 55 | export async function buildImportGraph( | |
| 56 | owner: string, | |
| 57 | repo: string, | |
| 58 | ref: string | |
| 59 | ): Promise<ImportGraph> { | |
| 60 | const repoDir = getRepoPath(owner, repo); | |
| 61 | ||
| 62 | // Get all source files | |
| 63 | const fileList = await exec( | |
| 64 | ["git", "ls-tree", "-r", "--name-only", ref], | |
| 65 | repoDir | |
| 66 | ); | |
| 67 | const sourceFiles = fileList | |
| 68 | .split("\n") | |
| 69 | .filter((f) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(f)); | |
| 70 | ||
| 71 | // Get package.json for dependency list | |
| 72 | let deps: Record<string, string> = {}; | |
| 73 | let devDeps: Record<string, string> = {}; | |
| 74 | try { | |
| 75 | const pkg = await exec( | |
| 76 | ["git", "show", `${ref}:package.json`], | |
| 77 | repoDir | |
| 78 | ); | |
| 79 | const parsed = JSON.parse(pkg); | |
| 80 | deps = parsed.dependencies || {}; | |
| 81 | devDeps = parsed.devDependencies || {}; | |
| 82 | } catch { | |
| 83 | // no package.json | |
| 84 | } | |
| 85 | ||
| 86 | const files: Record<string, string[]> = {}; | |
| 87 | const depUsage: Record<string, ImportUsage[]> = {}; | |
| 88 | ||
| 89 | for (const filePath of sourceFiles.slice(0, 100)) { | |
| 90 | const content = await exec( | |
| 91 | ["git", "show", `${ref}:${filePath}`], | |
| 92 | repoDir | |
| 93 | ); | |
| 94 | if (!content) continue; | |
| 95 | ||
| 96 | const imports: string[] = []; | |
| 97 | const lines = content.split("\n"); | |
| 98 | ||
| 99 | for (let i = 0; i < lines.length; i++) { | |
| 100 | const line = lines[i]; | |
| 101 | ||
| 102 | // Match: import ... from "..." | |
| 103 | const importMatch = line.match( | |
| 104 | /(?:import|export)\s+(?:(?:type\s+)?(?:\{[^}]*\}|[\w*]+(?:\s+as\s+\w+)?)\s+from\s+)?["']([^"']+)["']/ | |
| 105 | ); | |
| 106 | if (importMatch) { | |
| 107 | const target = importMatch[1]; | |
| 108 | imports.push(target); | |
| 109 | ||
| 110 | // Check if it's an external dep | |
| 111 | const depName = target.startsWith("@") | |
| 112 | ? target.split("/").slice(0, 2).join("/") | |
| 113 | : target.split("/")[0]; | |
| 114 | ||
| 115 | if (deps[depName] || devDeps[depName]) { | |
| 116 | // Extract imported symbols | |
| 117 | const symbolMatch = line.match(/\{([^}]+)\}/); | |
| 118 | const symbols = symbolMatch | |
| 119 | ? symbolMatch[1].split(",").map((s) => s.trim().split(" as ")[0].trim()).filter(Boolean) | |
| 120 | : ["*"]; | |
| 121 | ||
| 122 | if (!depUsage[depName]) depUsage[depName] = []; | |
| 123 | depUsage[depName].push({ | |
| 124 | file: filePath, | |
| 125 | line: i + 1, | |
| 126 | importedSymbols: symbols, | |
| 127 | importStatement: line.trim(), | |
| 128 | }); | |
| 129 | } | |
| 130 | } | |
| 131 | ||
| 132 | // Match: require("...") | |
| 133 | const requireMatch = line.match(/require\s*\(\s*["']([^"']+)["']\s*\)/); | |
| 134 | if (requireMatch) { | |
| 135 | imports.push(requireMatch[1]); | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | files[filePath] = imports; | |
| 140 | } | |
| 141 | ||
| 142 | // Build dependency map | |
| 143 | const dependencies: DependencyMap[] = []; | |
| 144 | const allDeps = { ...deps, ...devDeps }; | |
| 145 | for (const [name, version] of Object.entries(allDeps)) { | |
| 146 | dependencies.push({ | |
| 147 | name, | |
| 148 | version, | |
| 149 | usedIn: depUsage[name] || [], | |
| 150 | totalImports: (depUsage[name] || []).length, | |
| 151 | isDevDep: !!devDeps[name], | |
| 152 | }); | |
| 153 | } | |
| 154 | ||
| 155 | // Sort by usage | |
| 156 | dependencies.sort((a, b) => b.totalImports - a.totalImports); | |
| 157 | ||
| 158 | // Detect circular dependencies (simplified) | |
| 159 | const circularDeps = detectCircularDeps(files); | |
| 160 | ||
| 161 | return { | |
| 162 | files, | |
| 163 | dependencies, | |
| 164 | internalModules: sourceFiles.length, | |
| 165 | externalDependencies: Object.keys(allDeps).length, | |
| 166 | circularDeps, | |
| 167 | }; | |
| 168 | } | |
| 169 | ||
| 170 | /** | |
| 171 | * Analyze the impact of upgrading a specific dependency. | |
| 172 | */ | |
| 173 | export async function analyzeUpgradeImpact( | |
| 174 | owner: string, | |
| 175 | repo: string, | |
| 176 | ref: string, | |
| 177 | depName: string | |
| 178 | ): Promise<{ | |
| 179 | dependency: string; | |
| 180 | currentVersion: string; | |
| 181 | usedIn: ImportUsage[]; | |
| 182 | uniqueSymbols: string[]; | |
| 183 | riskLevel: "low" | "medium" | "high"; | |
| 184 | affectedFiles: number; | |
| 185 | recommendation: string; | |
| 186 | }> { | |
| 187 | const graph = await buildImportGraph(owner, repo, ref); | |
| 188 | const dep = graph.dependencies.find((d) => d.name === depName); | |
| 189 | ||
| 190 | if (!dep) { | |
| 191 | return { | |
| 192 | dependency: depName, | |
| 193 | currentVersion: "unknown", | |
| 194 | usedIn: [], | |
| 195 | uniqueSymbols: [], | |
| 196 | riskLevel: "low", | |
| 197 | affectedFiles: 0, | |
| 198 | recommendation: "Dependency not found in this project.", | |
| 199 | }; | |
| 200 | } | |
| 201 | ||
| 202 | const uniqueSymbols = [ | |
| 203 | ...new Set(dep.usedIn.flatMap((u) => u.importedSymbols)), | |
| 204 | ]; | |
| 205 | ||
| 206 | const affectedFiles = new Set(dep.usedIn.map((u) => u.file)).size; | |
| 207 | ||
| 208 | let riskLevel: "low" | "medium" | "high" = "low"; | |
| 209 | if (affectedFiles > 10 || uniqueSymbols.length > 15) riskLevel = "high"; | |
| 210 | else if (affectedFiles > 3 || uniqueSymbols.length > 5) riskLevel = "medium"; | |
| 211 | ||
| 212 | let recommendation: string; | |
| 213 | if (riskLevel === "high") { | |
| 214 | recommendation = `High risk: ${depName} is used in ${affectedFiles} files with ${uniqueSymbols.length} unique imports. Upgrade carefully with thorough testing.`; | |
| 215 | } else if (riskLevel === "medium") { | |
| 216 | recommendation = `Moderate risk: ${depName} is used in ${affectedFiles} files. Review changelog before upgrading.`; | |
| 217 | } else { | |
| 218 | recommendation = `Low risk: ${depName} has minimal usage (${affectedFiles} file${affectedFiles !== 1 ? "s" : ""}). Safe to upgrade.`; | |
| 219 | } | |
| 220 | ||
| 221 | return { | |
| 222 | dependency: depName, | |
| 223 | currentVersion: dep.version, | |
| 224 | usedIn: dep.usedIn, | |
| 225 | uniqueSymbols, | |
| 226 | riskLevel, | |
| 227 | affectedFiles, | |
| 228 | recommendation, | |
| 229 | }; | |
| 230 | } | |
| 231 | ||
| 232 | /** | |
| 233 | * Find unused dependencies — installed but never imported. | |
| 234 | */ | |
| 235 | export function findUnusedDeps(graph: ImportGraph): string[] { | |
| 236 | return graph.dependencies | |
| 237 | .filter((d) => d.totalImports === 0 && !d.isDevDep) | |
| 238 | .map((d) => d.name); | |
| 239 | } | |
| 240 | ||
| 241 | // Simple circular dependency detection | |
| 242 | function detectCircularDeps( | |
| 243 | files: Record<string, string[]> | |
| 244 | ): string[][] { | |
| 245 | const circular: string[][] = []; | |
| 246 | const visited = new Set<string>(); | |
| 247 | const stack = new Set<string>(); | |
| 248 | ||
| 249 | function dfs(file: string, path: string[]): void { | |
| 250 | if (stack.has(file)) { | |
| 251 | const cycleStart = path.indexOf(file); | |
| 252 | if (cycleStart !== -1) { | |
| 253 | circular.push(path.slice(cycleStart)); | |
| 254 | } | |
| 255 | return; | |
| 256 | } | |
| 257 | if (visited.has(file)) return; | |
| 258 | ||
| 259 | visited.add(file); | |
| 260 | stack.add(file); | |
| 261 | path.push(file); | |
| 262 | ||
| 263 | const imports = files[file] || []; | |
| 264 | for (const imp of imports) { | |
| 265 | // Resolve relative imports | |
| 266 | if (imp.startsWith(".")) { | |
| 267 | // Find matching file | |
| 268 | const resolved = Object.keys(files).find( | |
| 269 | (f) => | |
| 270 | f.endsWith(imp.replace(/^\.\//, "")) || | |
| 271 | f.endsWith(imp.replace(/^\.\//, "") + ".ts") || | |
| 272 | f.endsWith(imp.replace(/^\.\//, "") + ".tsx") | |
| 273 | ); | |
| 274 | if (resolved) { | |
| 275 | dfs(resolved, [...path]); | |
| 276 | } | |
| 277 | } | |
| 278 | } | |
| 279 | ||
| 280 | stack.delete(file); | |
| 281 | } | |
| 282 | ||
| 283 | for (const file of Object.keys(files)) { | |
| 284 | dfs(file, []); | |
| 285 | } | |
| 286 | ||
| 287 | return circular.slice(0, 10); // Limit results | |
| 288 | } |