impact-analysis 1.0.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ import { GoogleGenerativeAI } from '@google/generative-ai';
2
+
3
+ export async function explainImpact(results: any[]) {
4
+ if (!process.env.GEMINI_API_KEY) {
5
+ console.log('No GEMINI_API_KEY found. Skipping AI explanation.');
6
+ return null;
7
+ }
8
+
9
+ try {
10
+ const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
11
+ const model = genAI.getGenerativeModel({ model: 'gemini-3-flash-preview' });
12
+
13
+ const prompt = `Analyze these code changes and explain their impact:
14
+
15
+ ${JSON.stringify(results.map(r => ({
16
+ file: r.changedFile,
17
+ impactedFiles: r.impactedFiles.length,
18
+ risk: r.risk
19
+ })), null, 2)}
20
+
21
+ Provide a brief summary of the overall risk and key concerns.`;
22
+
23
+ const result = await model.generateContent(prompt);
24
+ const response = await result.response;
25
+ return response.text();
26
+ } catch (error) {
27
+ throw new Error(`AI explanation failed: ${error instanceof Error ? error.message : String(error)}`);
28
+ }
29
+ }
@@ -0,0 +1,49 @@
1
+ import fs from "fs";
2
+ import { DependencyGraph } from "../graph/types";
3
+
4
+ const CACHE_FILE = ".impact-analysis-cache.json";
5
+
6
+ export function loadCache(): DependencyGraph | null {
7
+ try {
8
+ if (!fs.existsSync(CACHE_FILE)) {
9
+ return null;
10
+ }
11
+
12
+ const data = JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8"));
13
+
14
+ return {
15
+ imports: new Map(
16
+ Object.entries(data.imports).map(([key, value]) => [key, new Set(value as string[])])
17
+ ),
18
+ importedBy: new Map(
19
+ Object.entries(data.importedBy).map(([key, value]) => [key, new Set(value as string[])])
20
+ ),
21
+ componentUsage: new Map(
22
+ Object.entries(data.componentUsage).map(([key, value]) => [key, new Set(value as string[])])
23
+ )
24
+ };
25
+ } catch (error) {
26
+ console.warn('Cache file corrupted, rebuilding...');
27
+ return null;
28
+ }
29
+ }
30
+
31
+ export function saveCache(data: DependencyGraph) {
32
+ try {
33
+ const serializable = {
34
+ imports: Object.fromEntries(
35
+ Array.from(data.imports.entries()).map(([key, value]) => [key, Array.from(value)])
36
+ ),
37
+ importedBy: Object.fromEntries(
38
+ Array.from(data.importedBy.entries()).map(([key, value]) => [key, Array.from(value)])
39
+ ),
40
+ componentUsage: Object.fromEntries(
41
+ Array.from(data.componentUsage.entries()).map(([key, value]) => [key, Array.from(value)])
42
+ )
43
+ };
44
+
45
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(serializable, null, 2));
46
+ } catch (error) {
47
+ console.warn('Failed to save cache:', error instanceof Error ? error.message : String(error));
48
+ }
49
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ import 'dotenv/config';
3
+ import path from 'path';
4
+ import { exec } from 'child_process';
5
+ import { promisify } from 'util';
6
+ import { getChangedFiles } from './git/getChangedFiles';
7
+ import { scanRepo } from './scanner/scanRepo';
8
+ import { buildGraph } from './graph/buildGraph';
9
+ import { analyzeImpact } from './core/analyzer';
10
+ import { generateHtmlReport } from './report/html-generator';
11
+ import { explainImpact } from './ai/explain';
12
+
13
+ const execAsync = promisify(exec);
14
+
15
+ async function main() {
16
+ const args = process.argv.slice(2);
17
+ const showHtml = args.includes('--html');
18
+ const useAI = args.includes('--ai');
19
+ const clearCache = args.includes('--clear-cache');
20
+ const baseBranch = args.find(arg => !arg.startsWith('--')) || 'main';
21
+
22
+ try {
23
+ if (clearCache) {
24
+ const fs = await import('fs');
25
+ if (fs.existsSync('.impact-analysis-cache.json')) {
26
+ fs.unlinkSync('.impact-analysis-cache.json');
27
+ console.log('Cache cleared.');
28
+ }
29
+ }
30
+
31
+ console.log('Scanning repository...');
32
+ const allFiles = await scanRepo();
33
+
34
+ console.log(`Building dependency graph from ${allFiles.length} files...`);
35
+ const graph = await buildGraph(allFiles);
36
+
37
+ console.log(`Getting changed files against ${baseBranch}...`);
38
+ const changedFiles = await getChangedFiles(baseBranch);
39
+
40
+ if (changedFiles.length === 0) {
41
+ console.log('No changed files found.');
42
+ return;
43
+ }
44
+
45
+ console.log(`Found ${changedFiles.length} changed files. Analyzing impact...`);
46
+
47
+ const dependencyMap: Record<string, string[]> = {};
48
+ graph.importedBy.forEach((importers, file) => {
49
+ dependencyMap[file] = Array.from(importers);
50
+ });
51
+
52
+ const results = analyzeImpact(changedFiles, dependencyMap);
53
+
54
+ if (useAI) {
55
+ try {
56
+ console.log('Getting AI explanation...');
57
+ const explanation = await explainImpact(results);
58
+ if (explanation) {
59
+ (results as any).aiExplanation = explanation;
60
+ }
61
+ } catch (error) {
62
+ console.warn('AI explanation failed:', error instanceof Error ? error.message : String(error));
63
+ }
64
+ }
65
+
66
+ console.log('\n--- Impact Analysis Results ---\n');
67
+ results.forEach((result, index) => {
68
+ console.log(`${index + 1}. ${path.basename(result.changedFile)}`);
69
+ console.log(` Risk: ${result.risk}`);
70
+ console.log(` Impacted files: ${result.impactedFiles.length}`);
71
+ if (result.impactedFiles.length > 0) {
72
+ result.impactedFiles.slice(0, 5).forEach(file => {
73
+ console.log(` - ${path.basename(file)}`);
74
+ });
75
+ if (result.impactedFiles.length > 5) {
76
+ console.log(` ... and ${result.impactedFiles.length - 5} more`);
77
+ }
78
+ }
79
+ console.log('');
80
+ });
81
+
82
+ // Generate HTML by default
83
+ console.log('Generating HTML report...');
84
+ const htmlPath = generateHtmlReport(results);
85
+ console.log(`Report saved: ${htmlPath}`);
86
+
87
+ if (showHtml) {
88
+ try {
89
+ // Use platform-specific command to open the file
90
+ const command = process.platform === 'win32'
91
+ ? `start "" "${htmlPath}"`
92
+ : process.platform === 'darwin'
93
+ ? `open "${htmlPath}"`
94
+ : `xdg-open "${htmlPath}"`;
95
+
96
+ await execAsync(command);
97
+ console.log(`Report opened in browser`);
98
+ } catch (err) {
99
+ console.log(`Could not auto-open browser. Please open: ${htmlPath}`);
100
+ }
101
+ } else {
102
+ console.log(`\nTo view the graphical report, run: impact-analysis --html`);
103
+ }
104
+ } catch (error) {
105
+ console.error('Error:', error instanceof Error ? error.message : String(error));
106
+ process.exit(1);
107
+ }
108
+ }
109
+
110
+ main();
@@ -0,0 +1,151 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { extractImports } from "../parser/parseJS";
4
+ import { parseVue } from "../parser/parseVue";
5
+ import { DependencyGraph } from "./types";
6
+ import { loadCache, saveCache } from "../cache/graphCache";
7
+
8
+ function resolveImportPath(importPath: string, fromFile: string, allFiles: string[]): string | null {
9
+ if (
10
+ (!importPath.startsWith('.') && !importPath.startsWith('/') &&
11
+ !importPath.startsWith('~') && !importPath.startsWith('@') &&
12
+ !importPath.startsWith('#')) ||
13
+ importPath.startsWith('virtual:') ||
14
+ importPath.startsWith('~icons/') ||
15
+ importPath.startsWith('unplugin-') ||
16
+ /\.(css|scss|sass|less|styl|stylus|png|jpg|jpeg|gif|svg|woff|woff2|ttf|eot)$/.test(importPath)
17
+ ) {
18
+ return null;
19
+ }
20
+
21
+ const baseDir = path.dirname(fromFile);
22
+ const projectRoot = process.cwd();
23
+ const extensions = ['.vue', '.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs', ''];
24
+ const indexFiles = ['/index.vue', '/index.js', '/index.ts', '/index.jsx', '/index.tsx', '/index.mjs'];
25
+
26
+ let resolvedBase: string | null = null;
27
+
28
+ // Handle various path alias patterns
29
+ if (importPath.startsWith('~/') || importPath.startsWith('@/')) {
30
+ // Common aliases: ~/ or @/ → project root
31
+ resolvedBase = path.join(projectRoot, importPath.substring(2));
32
+ } else if (importPath.startsWith('#')) {
33
+ // Nuxt 3 aliases: #app, #imports, #components
34
+ // Map to common locations
35
+ if (importPath.startsWith('#app') || importPath.startsWith('#imports')) {
36
+ return null; // Skip Nuxt internals
37
+ }
38
+ if (importPath.startsWith('#components/')) {
39
+ resolvedBase = path.join(projectRoot, 'components', importPath.substring(12));
40
+ } else {
41
+ return null;
42
+ }
43
+ } else if (importPath.startsWith('/')) {
44
+ // Absolute path from root
45
+ resolvedBase = path.join(projectRoot, importPath.substring(1));
46
+ } else if (importPath.startsWith('.')) {
47
+ // Relative path
48
+ resolvedBase = path.join(baseDir, importPath);
49
+ } else {
50
+ // Could be a tsconfig path alias (e.g., @components/...)
51
+ // Try common patterns
52
+ const aliasPatterns = [
53
+ ['@components/', 'components/'],
54
+ ['@utils/', 'utils/'],
55
+ ['@lib/', 'lib/'],
56
+ ['@src/', 'src/'],
57
+ ['@/', ''],
58
+ ];
59
+
60
+ for (const [alias, replacement] of aliasPatterns) {
61
+ if (importPath.startsWith(alias)) {
62
+ resolvedBase = path.join(projectRoot, importPath.replace(alias, replacement));
63
+ break;
64
+ }
65
+ }
66
+
67
+ if (!resolvedBase) {
68
+ return null; // Likely an external package
69
+ }
70
+ }
71
+
72
+ // Normalize path separators
73
+ resolvedBase = path.normalize(resolvedBase);
74
+
75
+ const hasExtension = extensions.some(ext => ext && importPath.endsWith(ext));
76
+
77
+ for (const ext of extensions) {
78
+ const withExt = hasExtension ? resolvedBase : resolvedBase + ext;
79
+
80
+ if (allFiles.includes(withExt)) {
81
+ return withExt;
82
+ }
83
+ }
84
+
85
+ for (const indexFile of indexFiles) {
86
+ const resolved = resolvedBase + indexFile;
87
+ if (allFiles.includes(resolved)) {
88
+ return resolved;
89
+ }
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ export async function buildGraph(files: string[]): Promise<DependencyGraph> {
96
+ const cached = loadCache();
97
+ if (cached) {
98
+ console.log('Using cached dependency graph...');
99
+ return cached;
100
+ }
101
+
102
+ const graph: DependencyGraph = {
103
+ imports: new Map(),
104
+ importedBy: new Map(),
105
+ componentUsage: new Map()
106
+ };
107
+
108
+ let filesProcessed = 0;
109
+
110
+ for (const file of files) {
111
+ const code = fs.readFileSync(file, "utf-8");
112
+
113
+ if (file.endsWith(".vue")) {
114
+ const { imports, components } = parseVue(code);
115
+
116
+ imports.forEach(imp => {
117
+ const resolved = resolveImportPath(imp, file, files);
118
+ if (resolved) {
119
+ graph.imports.set(file, (graph.imports.get(file) || new Set()).add(resolved));
120
+ graph.importedBy.set(resolved, (graph.importedBy.get(resolved) || new Set()).add(file));
121
+ }
122
+ });
123
+
124
+ components.forEach(component => {
125
+ graph.componentUsage.set(
126
+ component,
127
+ (graph.componentUsage.get(component) || new Set()).add(file)
128
+ );
129
+ });
130
+ } else {
131
+ const imports = extractImports(code);
132
+
133
+ imports.forEach(imp => {
134
+ const resolved = resolveImportPath(imp, file, files);
135
+ if (resolved) {
136
+ graph.imports.set(file, (graph.imports.get(file) || new Set()).add(resolved));
137
+ graph.importedBy.set(resolved, (graph.importedBy.get(resolved) || new Set()).add(file));
138
+ }
139
+ });
140
+ }
141
+
142
+ filesProcessed++;
143
+ }
144
+
145
+ console.log(`Dependency graph built: ${graph.importedBy.size} files with dependencies`);
146
+
147
+ saveCache(graph);
148
+
149
+ return graph;
150
+ }
151
+
@@ -0,0 +1,5 @@
1
+ export interface DependencyGraph {
2
+ imports: Map<string, Set<string>>;
3
+ importedBy: Map<string, Set<string>>;
4
+ componentUsage: Map<string, Set<string>>;
5
+ }
@@ -0,0 +1,69 @@
1
+ import { parse } from "@babel/parser";
2
+ import traverse from "@babel/traverse";
3
+
4
+ export function extractImports(code: string): string[] {
5
+ const imports: string[] = [];
6
+
7
+ try {
8
+ const ast = parse(code, {
9
+ sourceType: "unambiguous",
10
+ plugins: [
11
+ "typescript",
12
+ "jsx",
13
+ "decorators-legacy",
14
+ "classProperties",
15
+ "dynamicImport",
16
+ "importMeta",
17
+ "topLevelAwait",
18
+ "classStaticBlock",
19
+ "optionalChaining",
20
+ "nullishCoalescingOperator"
21
+ ],
22
+ errorRecovery: true
23
+ });
24
+
25
+ traverse(ast as any, {
26
+ // Static imports: import x from 'module'
27
+ ImportDeclaration(path) {
28
+ imports.push(path.node.source.value);
29
+ },
30
+ // Dynamic imports: import('module')
31
+ Import(path) {
32
+ const parent = path.parent;
33
+ if (parent.type === 'CallExpression' && parent.arguments[0]) {
34
+ const arg = parent.arguments[0];
35
+ if (arg.type === 'StringLiteral') {
36
+ imports.push(arg.value);
37
+ }
38
+ }
39
+ },
40
+ // Export from: export { x } from 'module'
41
+ ExportNamedDeclaration(path) {
42
+ if (path.node.source) {
43
+ imports.push(path.node.source.value);
44
+ }
45
+ },
46
+ // Export all: export * from 'module'
47
+ ExportAllDeclaration(path) {
48
+ imports.push(path.node.source.value);
49
+ },
50
+ // CommonJS require: const x = require('module')
51
+ CallExpression(path) {
52
+ if (
53
+ path.node.callee.type === 'Identifier' &&
54
+ path.node.callee.name === 'require' &&
55
+ path.node.arguments[0] &&
56
+ path.node.arguments[0].type === 'StringLiteral'
57
+ ) {
58
+ imports.push(path.node.arguments[0].value);
59
+ }
60
+ }
61
+ });
62
+ } catch (error) {
63
+ // Silently skip files with parse errors
64
+ // Most errors are from node_modules or generated files
65
+ }
66
+
67
+ // Remove duplicates
68
+ return [...new Set(imports)];
69
+ }
@@ -0,0 +1,23 @@
1
+ import { parse as parseSFC } from "@vue/compiler-sfc";
2
+ import { extractImports } from "./parseJS";
3
+ import { extractTemplateComponents } from "./parseVueTemplate";
4
+
5
+ export function parseVue(code: string) {
6
+ try {
7
+ const { descriptor } = parseSFC(code);
8
+
9
+ const scriptContent = descriptor.script?.content || '';
10
+ const scriptSetupContent = descriptor.scriptSetup?.content || '';
11
+ const allScript = scriptContent + '\n' + scriptSetupContent;
12
+
13
+ return {
14
+ imports: allScript.trim() ? extractImports(allScript) : [],
15
+ components: extractTemplateComponents(code)
16
+ };
17
+ } catch (error) {
18
+ return {
19
+ imports: [],
20
+ components: []
21
+ };
22
+ }
23
+ }
@@ -0,0 +1,16 @@
1
+ import { parse as parseSFC } from "@vue/compiler-sfc";
2
+
3
+ export function extractTemplateComponents(code: string): string[] {
4
+ const { descriptor } = parseSFC(code);
5
+ const template = descriptor.template?.content || "";
6
+
7
+ const componentRegex = /<([A-Z][\w-]*)/g;
8
+ const components = new Set<string>();
9
+
10
+ let match;
11
+ while ((match = componentRegex.exec(template))) {
12
+ components.add(match[1]);
13
+ }
14
+
15
+ return Array.from(components);
16
+ }