ProjectContextManager.ts•16.7 kB
/**
* ProjectContextManager
*
* Manages project-level context including:
* - Project structure analysis
* - Framework and dependency detection
* - Git history and recent changes
* - Context caching
*/
import * as fs from 'fs-extra';
import * as path from 'path';
import { glob } from 'glob';
import { simpleGit, SimpleGit } from 'simple-git';
import * as chokidar from 'chokidar';
import { debounce } from 'lodash';
import logger from '../utils/Logger';
// Configuration type definitions
export interface ProjectContextConfig {
projectPath: string;
ignorePatterns: string[];
cacheTTL: number; // Time-to-live for context cache in seconds
maxContextSize: number; // Maximum tokens for context
}
// Framework detection patterns
const FRAMEWORK_PATTERNS = {
react: ['react', 'jsx', 'tsx', 'createContext', 'useState', 'useEffect'],
vue: ['vue', 'createApp', 'defineComponent', '<template>', '<script setup>'],
angular: ['@angular', 'NgModule', 'Component', 'Injectable'],
nextjs: ['next.config', 'getStaticProps', 'getServerSideProps', '_app.tsx', 'pages/'],
express: ['express', 'app.use', 'app.get', 'app.post', 'Router()'],
nestjs: ['@nestjs', '@Controller', '@Injectable', '@Module'],
};
// Project structure context
export interface ProjectContext {
timestamp: number;
projectName: string;
frameworks: string[];
dependencies: Record<string, string>;
devDependencies: Record<string, string>;
fileStructure: FileStructure;
recentChanges: GitChange[];
branchInfo: GitBranchInfo;
patterns: CodePattern[];
}
// File structure representation
export interface FileStructure {
directories: Record<string, FileStructure>;
files: string[];
}
// Git change information
export interface GitChange {
file: string;
status: string;
date: string;
author: string;
message: string;
}
// Git branch information
export interface GitBranchInfo {
currentBranch: string;
branches: string[];
remotes: string[];
}
// Code pattern information
export interface CodePattern {
type: string; // e.g., 'component', 'route', 'api', 'model'
pattern: string;
examples: string[];
}
export class ProjectContextManager {
private config: ProjectContextConfig;
private context: ProjectContext | null = null;
private lastUpdateTime: number = 0;
private fileWatcher: chokidar.FSWatcher | null = null;
private git: SimpleGit | null = null;
private log = logger.createChildLogger('ProjectContextManager');
constructor(config: Partial<ProjectContextConfig> = {}) {
this.config = {
projectPath: config.projectPath || process.cwd(),
ignorePatterns: config.ignorePatterns || [
'node_modules',
'dist',
'.git',
'build',
'coverage',
'*.log',
],
cacheTTL: config.cacheTTL || 900, // 15 minutes by default
maxContextSize: config.maxContextSize || 10000,
};
try {
this.git = simpleGit(this.config.projectPath);
} catch (error) {
this.log.warn('Git integration not available', error);
}
this.setupFileWatcher();
}
/**
* Get the project context, refreshing if needed
*/
public async getContext(forceRefresh = false): Promise<ProjectContext> {
const now = Date.now();
const cacheExpired = now - this.lastUpdateTime > this.config.cacheTTL * 1000;
if (!this.context || forceRefresh || cacheExpired) {
this.log.info('Refreshing project context...');
this.context = await this.analyzeProject();
this.lastUpdateTime = now;
}
return this.context;
}
/**
* Analyze the entire project and build context
*/
private async analyzeProject(): Promise<ProjectContext> {
try {
const projectPath = this.config.projectPath;
// Get project name from package.json or directory name
let projectName = path.basename(projectPath);
const packageJsonPath = path.join(projectPath, 'package.json');
// Initialize context structure
const context: ProjectContext = {
timestamp: Date.now(),
projectName,
frameworks: [],
dependencies: {},
devDependencies: {},
fileStructure: { directories: {}, files: [] },
recentChanges: [],
branchInfo: { currentBranch: '', branches: [], remotes: [] },
patterns: [],
};
// Read package.json if exists
if (await fs.pathExists(packageJsonPath)) {
try {
const packageJson = await fs.readJson(packageJsonPath);
context.projectName = packageJson.name || projectName;
context.dependencies = packageJson.dependencies || {};
context.devDependencies = packageJson.devDependencies || {};
// Infer frameworks from dependencies
context.frameworks = this.detectFrameworks(packageJson);
} catch (error) {
this.log.error('Error parsing package.json', error as Error);
}
}
// Build file structure
context.fileStructure = await this.buildFileStructure(projectPath);
// Get git information if available
if (this.git) {
try {
// Get recent changes
const gitLog = await this.git.log({ maxCount: 10 });
context.recentChanges = gitLog.all.map(commit => {
return {
file: '', // Will be filled in with diff info in a production version
status: 'committed',
date: new Date(commit.date).toISOString(),
author: commit.author_name,
message: commit.message,
};
});
// Get branch info
const branches = await this.git.branchLocal();
const remotes = await this.git.getRemotes(true);
context.branchInfo = {
currentBranch: branches.current,
branches: branches.all,
remotes: remotes.map(remote => `${remote.name}: ${remote.refs.fetch}`),
};
} catch (error) {
this.log.warn('Error getting git information', error as Error);
}
}
// Detect code patterns
context.patterns = await this.detectCodePatterns(projectPath, context.frameworks);
this.log.info(`Project context refreshed for ${context.projectName}`);
return context;
} catch (error) {
this.log.error('Error analyzing project', error as Error);
throw new Error(`Failed to analyze project: ${(error as Error).message}`);
}
}
/**
* Detect frameworks used in the project
*/
private detectFrameworks(packageJson: any): string[] {
const frameworks: string[] = [];
const allDependencies = {
...(packageJson.dependencies || {}),
...(packageJson.devDependencies || {})
};
// Check dependencies
for (const [framework, patterns] of Object.entries(FRAMEWORK_PATTERNS)) {
const hasFramework = patterns.some(pattern =>
Object.keys(allDependencies).some(dep => dep.includes(pattern))
);
if (hasFramework) {
frameworks.push(framework);
}
}
// Check for custom configuration files
const configFiles = Object.keys(packageJson.scripts || {}).join(' ');
if (!frameworks.includes('nextjs') && configFiles.includes('next')) {
frameworks.push('nextjs');
}
if (!frameworks.includes('react') && (configFiles.includes('react-scripts') || configFiles.includes('vite'))) {
frameworks.push('react');
}
if (!frameworks.includes('vue') && configFiles.includes('vue-cli-service')) {
frameworks.push('vue');
}
return frameworks;
}
/**
* Build file structure recursively
*/
private async buildFileStructure(dirPath: string, isRoot = true): Promise<FileStructure> {
const structure: FileStructure = { directories: {}, files: [] };
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(dirPath, entry.name);
// Skip ignored patterns
if (isRoot && this.shouldIgnore(entry.name)) {
continue;
}
if (entry.isDirectory()) {
// Process subdirectory
structure.directories[entry.name] = await this.buildFileStructure(entryPath, false);
} else {
// Add file
structure.files.push(entry.name);
}
}
} catch (error) {
this.log.warn(`Error reading directory ${dirPath}`, error as Error);
}
return structure;
}
/**
* Check if a path should be ignored
*/
private shouldIgnore(pathName: string): boolean {
return this.config.ignorePatterns.some(pattern => {
// Exact match
if (pathName === pattern) {
return true;
}
// Directory match
if (pathName.startsWith(pattern + '/')) {
return true;
}
// Handle glob patterns safely
try {
// Escape special regex characters except * which we convert to .*
const regexPattern = pattern
.replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape regex special chars
.replace(/\*/g, '.*'); // Convert * to .*
return new RegExp(`^${regexPattern}$`).test(pathName);
} catch (error) {
this.log.warn(`Invalid pattern: ${pattern}`, error as Error);
return false;
}
});
}
/**
* Detect code patterns in the project
*/
private async detectCodePatterns(projectPath: string, frameworks: string[]): Promise<CodePattern[]> {
const patterns: CodePattern[] = [];
try {
// Add framework-specific pattern detection here
if (frameworks.includes('react') || frameworks.includes('nextjs')) {
// React component patterns
patterns.push(await this.detectReactPatterns(projectPath));
}
if (frameworks.includes('express')) {
// Express route patterns
patterns.push(await this.detectExpressPatterns(projectPath));
}
// Generic patterns (functions, classes, etc.)
patterns.push(...await this.detectGenericPatterns(projectPath));
} catch (error) {
this.log.warn('Error detecting code patterns', error as Error);
}
return patterns.filter(Boolean) as CodePattern[];
}
/**
* Detect React component patterns
*/
private async detectReactPatterns(projectPath: string): Promise<CodePattern> {
const pattern: CodePattern = {
type: 'component',
pattern: 'React component definition',
examples: []
};
try {
// Find React component files
const files = await glob('**/*.{jsx,tsx}', {
cwd: projectPath,
ignore: this.config.ignorePatterns
});
// Sample a few files
const sampleFiles = files.slice(0, 3);
for (const file of sampleFiles) {
const filePath = path.join(projectPath, file);
const content = await fs.readFile(filePath, 'utf-8');
// Extract component definition
const componentMatch = content.match(/function\s+(\w+)\s*\([^)]*\)\s*{|const\s+(\w+)\s*=\s*\([^)]*\)\s*=>/);
if (componentMatch) {
const snippet = this.extractCodeSnippet(content, componentMatch.index || 0);
if (snippet) {
pattern.examples.push(snippet);
}
}
}
} catch (error) {
this.log.warn('Error detecting React patterns', error as Error);
}
return pattern;
}
/**
* Detect Express route patterns
*/
private async detectExpressPatterns(projectPath: string): Promise<CodePattern> {
const pattern: CodePattern = {
type: 'route',
pattern: 'Express route definition',
examples: []
};
try {
// Find Express route files
const files = await glob('**/*.{js,ts}', {
cwd: projectPath,
ignore: this.config.ignorePatterns
});
// Sample a few files
const sampleFiles = files.slice(0, 3);
for (const file of sampleFiles) {
const filePath = path.join(projectPath, file);
const content = await fs.readFile(filePath, 'utf-8');
// Extract route definition
const routeMatch = content.match(/app\.(get|post|put|delete)\s*\(['"]\//);
if (routeMatch) {
const snippet = this.extractCodeSnippet(content, routeMatch.index || 0);
if (snippet) {
pattern.examples.push(snippet);
}
}
}
} catch (error) {
this.log.warn('Error detecting Express patterns', error as Error);
}
return pattern;
}
/**
* Detect generic code patterns
*/
private async detectGenericPatterns(projectPath: string): Promise<CodePattern[]> {
const patterns: CodePattern[] = [];
try {
// Function pattern
const functionPattern: CodePattern = {
type: 'function',
pattern: 'Function definition',
examples: []
};
// Class pattern
const classPattern: CodePattern = {
type: 'class',
pattern: 'Class definition',
examples: []
};
// Find all code files
const files = await glob('**/*.{js,ts,jsx,tsx}', {
cwd: projectPath,
ignore: this.config.ignorePatterns
});
// Sample a few files
const sampleFiles = files.slice(0, 3);
for (const file of sampleFiles) {
const filePath = path.join(projectPath, file);
const content = await fs.readFile(filePath, 'utf-8');
// Find function definitions
const functionMatches = content.matchAll(/function\s+(\w+)\s*\([^)]*\)\s*{/g);
for (const match of functionMatches) {
const snippet = this.extractCodeSnippet(content, match.index || 0);
if (snippet && functionPattern.examples.length < 3) {
functionPattern.examples.push(snippet);
}
}
// Find class definitions
const classMatches = content.matchAll(/class\s+(\w+)(\s+extends\s+\w+)?\s*{/g);
for (const match of classMatches) {
const snippet = this.extractCodeSnippet(content, match.index || 0);
if (snippet && classPattern.examples.length < 3) {
classPattern.examples.push(snippet);
}
}
}
if (functionPattern.examples.length > 0) {
patterns.push(functionPattern);
}
if (classPattern.examples.length > 0) {
patterns.push(classPattern);
}
} catch (error) {
this.log.warn('Error detecting generic patterns', error as Error);
}
return patterns;
}
/**
* Extract a code snippet from the content starting at the given index
*/
private extractCodeSnippet(content: string, startIndex: number): string | null {
try {
// Get up to 10 lines
const endOfContent = content.indexOf('\n', startIndex + 200) || content.length;
const snippet = content.substring(startIndex, endOfContent);
// Get a few lines
const lines = snippet.split('\n').slice(0, 10);
return lines.join('\n').trim();
} catch (error) {
return null;
}
}
/**
* Set up file watcher to trigger context updates
*/
private setupFileWatcher(): void {
try {
// Create debounced update function
const debouncedUpdate = debounce(() => {
this.log.debug('Files changed, invalidating context cache');
this.lastUpdateTime = 0; // Invalidate cache
}, 5000); // Debounce for 5 seconds
// Set up file watcher
const ignored = this.config.ignorePatterns.map(pattern =>
new RegExp(`(^|/)${pattern.replace(/\*/g, '.*')}(/|$)`)
);
this.fileWatcher = chokidar.watch(this.config.projectPath, {
ignored,
persistent: true,
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 1000,
pollInterval: 100
}
});
this.fileWatcher
.on('add', debouncedUpdate)
.on('change', debouncedUpdate)
.on('unlink', debouncedUpdate)
.on('ready', () => {
this.log.debug('File watcher initialized');
})
.on('error', (error) => {
this.log.error('File watcher error', error);
});
} catch (error) {
this.log.warn('Error setting up file watcher', error as Error);
}
}
/**
* Clean up resources
*/
public dispose(): void {
if (this.fileWatcher) {
this.fileWatcher.close().catch(error => {
this.log.warn('Error closing file watcher', error);
});
}
}
}
export default ProjectContextManager;