Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

depimpact.tsBlame288 lines · 1 contributor
16b325cClaude1/**
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
12import { getRepoPath } from "../git/repository";
13
14export interface DependencyMap {
15 name: string;
16 version: string;
17 usedIn: ImportUsage[];
18 totalImports: number;
19 isDevDep: boolean;
20}
21
22export interface ImportUsage {
23 file: string;
24 line: number;
25 importedSymbols: string[];
26 importStatement: string;
27}
28
29export 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
37async 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 */
55export 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 */
173export 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 */
235export 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
242function 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}