392 lines
11 KiB
TypeScript
392 lines
11 KiB
TypeScript
import * as plugins from '../plugins.js';
|
|
import type {
|
|
IFileMetadata,
|
|
IFileDependencies,
|
|
IFileAnalysis,
|
|
IAnalysisResult,
|
|
TaskType,
|
|
IPrioritizationWeights,
|
|
ITierConfig,
|
|
} from './types.js';
|
|
|
|
/**
|
|
* ContextAnalyzer provides intelligent file selection and prioritization
|
|
* based on dependency analysis, task relevance, and configurable weights
|
|
*/
|
|
export class ContextAnalyzer {
|
|
private projectRoot: string;
|
|
private weights: Required<IPrioritizationWeights>;
|
|
private tiers: Required<ITierConfig>;
|
|
|
|
/**
|
|
* Creates a new ContextAnalyzer
|
|
* @param projectRoot - Root directory of the project
|
|
* @param weights - Prioritization weights
|
|
* @param tiers - Tier configuration
|
|
*/
|
|
constructor(
|
|
projectRoot: string,
|
|
weights: Partial<IPrioritizationWeights> = {},
|
|
tiers: Partial<ITierConfig> = {}
|
|
) {
|
|
this.projectRoot = projectRoot;
|
|
|
|
// Default weights
|
|
this.weights = {
|
|
dependencyWeight: weights.dependencyWeight ?? 0.3,
|
|
relevanceWeight: weights.relevanceWeight ?? 0.4,
|
|
efficiencyWeight: weights.efficiencyWeight ?? 0.2,
|
|
recencyWeight: weights.recencyWeight ?? 0.1,
|
|
};
|
|
|
|
// Default tiers
|
|
this.tiers = {
|
|
essential: tiers.essential ?? { minScore: 0.8, trimLevel: 'none' },
|
|
important: tiers.important ?? { minScore: 0.5, trimLevel: 'light' },
|
|
optional: tiers.optional ?? { minScore: 0.2, trimLevel: 'aggressive' },
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Analyzes files for a specific task type
|
|
* @param metadata - Array of file metadata to analyze
|
|
* @param taskType - Type of task being performed
|
|
* @param changedFiles - Optional list of recently changed files (for commits)
|
|
* @returns Analysis result with scored files
|
|
*/
|
|
public async analyze(
|
|
metadata: IFileMetadata[],
|
|
taskType: TaskType,
|
|
changedFiles: string[] = []
|
|
): Promise<IAnalysisResult> {
|
|
const startTime = Date.now();
|
|
|
|
// Build dependency graph
|
|
const dependencyGraph = await this.buildDependencyGraph(metadata);
|
|
|
|
// Calculate centrality scores
|
|
this.calculateCentrality(dependencyGraph);
|
|
|
|
// Analyze each file
|
|
const files: IFileAnalysis[] = [];
|
|
for (const meta of metadata) {
|
|
const analysis = await this.analyzeFile(
|
|
meta,
|
|
taskType,
|
|
dependencyGraph,
|
|
changedFiles
|
|
);
|
|
files.push(analysis);
|
|
}
|
|
|
|
// Sort by importance score (highest first)
|
|
files.sort((a, b) => b.importanceScore - a.importanceScore);
|
|
|
|
const analysisDuration = Date.now() - startTime;
|
|
|
|
return {
|
|
taskType,
|
|
files,
|
|
dependencyGraph,
|
|
totalFiles: metadata.length,
|
|
analysisDuration,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Builds a dependency graph from file metadata
|
|
* @param metadata - Array of file metadata
|
|
* @returns Dependency graph as a map
|
|
*/
|
|
private async buildDependencyGraph(
|
|
metadata: IFileMetadata[]
|
|
): Promise<Map<string, IFileDependencies>> {
|
|
const graph = new Map<string, IFileDependencies>();
|
|
|
|
// Initialize graph entries
|
|
for (const meta of metadata) {
|
|
graph.set(meta.path, {
|
|
path: meta.path,
|
|
imports: [],
|
|
importedBy: [],
|
|
centrality: 0,
|
|
});
|
|
}
|
|
|
|
// Parse imports from each file
|
|
for (const meta of metadata) {
|
|
try {
|
|
const contents = await plugins.smartfile.fs.toStringSync(meta.path);
|
|
const imports = this.extractImports(contents, meta.path);
|
|
|
|
const deps = graph.get(meta.path)!;
|
|
deps.imports = imports;
|
|
|
|
// Update importedBy for imported files
|
|
for (const importPath of imports) {
|
|
const importedDeps = graph.get(importPath);
|
|
if (importedDeps) {
|
|
importedDeps.importedBy.push(meta.path);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn(`Failed to parse imports from ${meta.path}:`, error.message);
|
|
}
|
|
}
|
|
|
|
return graph;
|
|
}
|
|
|
|
/**
|
|
* Extracts import statements from file contents
|
|
* @param contents - File contents
|
|
* @param filePath - Path of the file being analyzed
|
|
* @returns Array of absolute paths to imported files
|
|
*/
|
|
private extractImports(contents: string, filePath: string): string[] {
|
|
const imports: string[] = [];
|
|
const fileDir = plugins.path.dirname(filePath);
|
|
|
|
// Match various import patterns
|
|
const importRegex = /(?:import|export).*?from\s+['"](.+?)['"]/g;
|
|
let match;
|
|
|
|
while ((match = importRegex.exec(contents)) !== null) {
|
|
const importPath = match[1];
|
|
|
|
// Skip external modules
|
|
if (!importPath.startsWith('.')) {
|
|
continue;
|
|
}
|
|
|
|
// Resolve relative import to absolute path
|
|
let resolvedPath = plugins.path.resolve(fileDir, importPath);
|
|
|
|
// Handle various file extensions
|
|
const extensions = ['.ts', '.js', '.tsx', '.jsx', '/index.ts', '/index.js'];
|
|
let found = false;
|
|
|
|
for (const ext of extensions) {
|
|
const testPath = resolvedPath.endsWith(ext) ? resolvedPath : resolvedPath + ext;
|
|
try {
|
|
// Use synchronous file check to avoid async in this context
|
|
const fs = require('fs');
|
|
const exists = fs.existsSync(testPath);
|
|
if (exists) {
|
|
imports.push(testPath);
|
|
found = true;
|
|
break;
|
|
}
|
|
} catch (error) {
|
|
// Continue trying other extensions
|
|
}
|
|
}
|
|
|
|
if (!found && !resolvedPath.includes('.')) {
|
|
// Try with .ts extension as default
|
|
imports.push(resolvedPath + '.ts');
|
|
}
|
|
}
|
|
|
|
return imports;
|
|
}
|
|
|
|
/**
|
|
* Calculates centrality scores for all nodes in the dependency graph
|
|
* Uses a simplified PageRank-like algorithm
|
|
* @param graph - Dependency graph
|
|
*/
|
|
private calculateCentrality(graph: Map<string, IFileDependencies>): void {
|
|
const damping = 0.85;
|
|
const iterations = 10;
|
|
const nodeCount = graph.size;
|
|
|
|
// Initialize scores
|
|
const scores = new Map<string, number>();
|
|
for (const path of graph.keys()) {
|
|
scores.set(path, 1.0 / nodeCount);
|
|
}
|
|
|
|
// Iterative calculation
|
|
for (let i = 0; i < iterations; i++) {
|
|
const newScores = new Map<string, number>();
|
|
|
|
for (const [path, deps] of graph.entries()) {
|
|
let score = (1 - damping) / nodeCount;
|
|
|
|
// Add contributions from nodes that import this file
|
|
for (const importerPath of deps.importedBy) {
|
|
const importerDeps = graph.get(importerPath);
|
|
if (importerDeps) {
|
|
const importerScore = scores.get(importerPath) ?? 0;
|
|
const outgoingCount = importerDeps.imports.length || 1;
|
|
score += damping * (importerScore / outgoingCount);
|
|
}
|
|
}
|
|
|
|
newScores.set(path, score);
|
|
}
|
|
|
|
// Update scores
|
|
for (const [path, score] of newScores) {
|
|
scores.set(path, score);
|
|
}
|
|
}
|
|
|
|
// Normalize scores to 0-1 range
|
|
const maxScore = Math.max(...scores.values());
|
|
if (maxScore > 0) {
|
|
for (const deps of graph.values()) {
|
|
const score = scores.get(deps.path) ?? 0;
|
|
deps.centrality = score / maxScore;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Analyzes a single file
|
|
* @param meta - File metadata
|
|
* @param taskType - Task being performed
|
|
* @param graph - Dependency graph
|
|
* @param changedFiles - Recently changed files
|
|
* @returns File analysis
|
|
*/
|
|
private async analyzeFile(
|
|
meta: IFileMetadata,
|
|
taskType: TaskType,
|
|
graph: Map<string, IFileDependencies>,
|
|
changedFiles: string[]
|
|
): Promise<IFileAnalysis> {
|
|
const deps = graph.get(meta.path);
|
|
const centralityScore = deps?.centrality ?? 0;
|
|
|
|
// Calculate task-specific relevance
|
|
const relevanceScore = this.calculateRelevance(meta, taskType);
|
|
|
|
// Calculate efficiency (information per token)
|
|
const efficiencyScore = this.calculateEfficiency(meta);
|
|
|
|
// Calculate recency (for commit tasks)
|
|
const recencyScore = this.calculateRecency(meta, changedFiles);
|
|
|
|
// Calculate combined importance score
|
|
const importanceScore =
|
|
relevanceScore * this.weights.relevanceWeight +
|
|
centralityScore * this.weights.dependencyWeight +
|
|
efficiencyScore * this.weights.efficiencyWeight +
|
|
recencyScore * this.weights.recencyWeight;
|
|
|
|
// Assign tier
|
|
const tier = this.assignTier(importanceScore);
|
|
|
|
return {
|
|
path: meta.path,
|
|
relevanceScore,
|
|
centralityScore,
|
|
efficiencyScore,
|
|
recencyScore,
|
|
importanceScore,
|
|
tier,
|
|
reason: this.generateReason(meta, taskType, importanceScore, tier),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculates task-specific relevance score
|
|
*/
|
|
private calculateRelevance(meta: IFileMetadata, taskType: TaskType): number {
|
|
const relativePath = meta.relativePath.toLowerCase();
|
|
let score = 0.5; // Base score
|
|
|
|
// README generation - prioritize public APIs and main exports
|
|
if (taskType === 'readme') {
|
|
if (relativePath.includes('index.ts')) score += 0.3;
|
|
if (relativePath.match(/^ts\/[^\/]+\.ts$/)) score += 0.2; // Root level exports
|
|
if (relativePath.includes('test/')) score -= 0.3;
|
|
if (relativePath.includes('classes/')) score += 0.1;
|
|
if (relativePath.includes('interfaces/')) score += 0.1;
|
|
}
|
|
|
|
// Commit messages - prioritize changed files and their dependencies
|
|
if (taskType === 'commit') {
|
|
if (relativePath.includes('test/')) score -= 0.2;
|
|
// Recency will handle changed files
|
|
}
|
|
|
|
// Description generation - prioritize main exports and core interfaces
|
|
if (taskType === 'description') {
|
|
if (relativePath.includes('index.ts')) score += 0.4;
|
|
if (relativePath.match(/^ts\/[^\/]+\.ts$/)) score += 0.3;
|
|
if (relativePath.includes('test/')) score -= 0.4;
|
|
if (relativePath.includes('interfaces/')) score += 0.2;
|
|
}
|
|
|
|
return Math.max(0, Math.min(1, score));
|
|
}
|
|
|
|
/**
|
|
* Calculates efficiency score (information density)
|
|
*/
|
|
private calculateEfficiency(meta: IFileMetadata): number {
|
|
// Prefer files that are not too large (good signal-to-noise ratio)
|
|
const optimalSize = 5000; // ~1250 tokens
|
|
const distance = Math.abs(meta.estimatedTokens - optimalSize);
|
|
const normalized = Math.max(0, 1 - distance / optimalSize);
|
|
|
|
return normalized;
|
|
}
|
|
|
|
/**
|
|
* Calculates recency score for changed files
|
|
*/
|
|
private calculateRecency(meta: IFileMetadata, changedFiles: string[]): number {
|
|
if (changedFiles.length === 0) {
|
|
return 0;
|
|
}
|
|
|
|
// Check if this file was changed
|
|
const isChanged = changedFiles.some((changed) => changed === meta.path);
|
|
|
|
return isChanged ? 1.0 : 0.0;
|
|
}
|
|
|
|
/**
|
|
* Assigns a tier based on importance score
|
|
*/
|
|
private assignTier(score: number): 'essential' | 'important' | 'optional' | 'excluded' {
|
|
if (score >= this.tiers.essential.minScore) return 'essential';
|
|
if (score >= this.tiers.important.minScore) return 'important';
|
|
if (score >= this.tiers.optional.minScore) return 'optional';
|
|
return 'excluded';
|
|
}
|
|
|
|
/**
|
|
* Generates a human-readable reason for the score
|
|
*/
|
|
private generateReason(
|
|
meta: IFileMetadata,
|
|
taskType: TaskType,
|
|
score: number,
|
|
tier: string
|
|
): string {
|
|
const reasons: string[] = [];
|
|
|
|
if (meta.relativePath.includes('index.ts')) {
|
|
reasons.push('main export file');
|
|
}
|
|
|
|
if (meta.relativePath.includes('test/')) {
|
|
reasons.push('test file (lower priority)');
|
|
}
|
|
|
|
if (taskType === 'readme' && meta.relativePath.match(/^ts\/[^\/]+\.ts$/)) {
|
|
reasons.push('root-level module');
|
|
}
|
|
|
|
reasons.push(`score: ${score.toFixed(2)}`);
|
|
reasons.push(`tier: ${tier}`);
|
|
|
|
return reasons.join(', ');
|
|
}
|
|
}
|