UNPKG

@ooples/token-optimizer-mcp

Version:

Intelligent context window optimization for Claude Code - store content externally via caching and compression, freeing up your context window for what matters

460 lines 17 kB
/** * Smart Lint Tool - 75% Token Reduction * * Wraps ESLint to provide: * - Cached lint results per file * - Show only new issues * - Auto-fix suggestions * - Ignore previously acknowledged issues */ import { spawn } from 'child_process'; import { CacheEngine } from '../../core/cache-engine.js'; import { TokenCounter } from '../../core/token-counter.js'; import { MetricsCollector } from '../../core/metrics.js'; import { createHash } from 'crypto'; import { readFileSync, existsSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; export class SmartLint { cache; cacheNamespace = 'smart_lint'; projectRoot; ignoredIssuesKey = 'ignored_issues'; constructor(cache, _tokenCounter, _metrics, projectRoot) { this.cache = cache; this.projectRoot = projectRoot || process.cwd(); } /** * Run lint with smart caching and output reduction */ async run(options = {}) { const { files = 'src', force = false, fix = false, onlyNew = true, includeIgnored = false, maxCacheAge = 3600, } = options; // Generate cache key const filePatterns = Array.isArray(files) ? files : [files]; const cacheKey = await this.generateCacheKey(filePatterns); // Check cache first (unless force) if (!force) { const cached = this.getCachedResult(cacheKey, maxCacheAge); if (cached) { return this.formatCachedOutput(cached, onlyNew); } } // Run ESLint const result = await this.runEslint({ files: filePatterns, fix, }); // Cache the result this.cacheResult(cacheKey, result); // Transform to smart output return this.transformOutput(result, onlyNew, includeIgnored); } /** * Run ESLint and capture results */ async runEslint(options) { const args = [...options.files, '--format=json']; if (options.fix) { args.push('--fix'); } return new Promise((resolve, reject) => { let stdout = ''; let stderr = ''; const eslint = spawn('npx', ['eslint', ...args], { cwd: this.projectRoot, shell: true, }); eslint.stdout.on('data', (data) => { stdout += data.toString(); }); eslint.stderr.on('data', (data) => { stderr += data.toString(); }); eslint.on('close', (_code) => { try { // ESLint outputs JSON even on errors const result = JSON.parse(stdout); const output = { results: result, errorCount: result.reduce((sum, r) => sum + r.errorCount, 0), warningCount: result.reduce((sum, r) => sum + r.warningCount, 0), fixableErrorCount: result.reduce((sum, r) => sum + r.fixableErrorCount, 0), fixableWarningCount: result.reduce((sum, r) => sum + r.fixableWarningCount, 0), }; resolve(output); } catch (err) { reject(new Error(`Failed to parse ESLint output: ${stderr || stdout}`)); } }); eslint.on('error', (err) => { reject(err); }); }); } /** * Generate cache key based on source files */ async generateCacheKey(files) { const hash = createHash('sha256'); hash.update(this.cacheNamespace); hash.update(files.join(':')); // Hash eslint config const eslintConfigPath = join(this.projectRoot, 'eslint.config.js'); if (existsSync(eslintConfigPath)) { const content = readFileSync(eslintConfigPath, 'utf-8'); hash.update(content); } // Hash package.json for dependency changes const packageJsonPath = join(this.projectRoot, 'package.json'); if (existsSync(packageJsonPath)) { const packageJson = readFileSync(packageJsonPath, 'utf-8'); // Only hash eslint-related dependencies const pkg = JSON.parse(packageJson); const eslintDeps = Object.keys(pkg.devDependencies || {}) .filter((dep) => dep.includes('eslint')) .sort() .map((dep) => `${dep}:${pkg.devDependencies[dep]}`) .join(','); hash.update(eslintDeps); } return `${this.cacheNamespace}:${hash.digest('hex')}`; } /** * Get cached result if available and fresh */ getCachedResult(key, maxAge) { const cached = this.cache.get(key); if (!cached) { return null; } try { const result = JSON.parse(cached); const age = (Date.now() - result.cachedAt) / 1000; if (age <= maxAge) { return result; } } catch (err) { return null; } return null; } /** * Cache lint result */ cacheResult(key, result) { const toCache = { ...result, cachedAt: Date.now(), }; const dataToCache = JSON.stringify(toCache); const originalSize = this.estimateOriginalOutputSize(result); const compactSize = dataToCache.length; this.cache.set(key, dataToCache, originalSize, compactSize); } /** * Get previously ignored issues */ getIgnoredIssues() { const cached = this.cache.get(this.ignoredIssuesKey); if (!cached) { return new Set(); } try { const ignored = JSON.parse(cached); return new Set(ignored); } catch (err) { return new Set(); } } /** * Generate issue key for tracking */ generateIssueKey(file, line, ruleId) { return `${file}:${line}:${ruleId}`; } /** * Compare with previous run to detect new issues */ detectNewIssues(_current) { // In a real implementation, we'd compare with previous cached run // For now, return empty array (all issues are "new") return []; } /** * Transform full lint output to smart output */ transformOutput(result, _onlyNew, includeIgnored, fromCache = false) { // Get ignored issues const ignoredSet = this.getIgnoredIssues(); // Group issues by rule const issuesByRule = new Map(); for (const fileResult of result.results) { for (const message of fileResult.messages) { const key = this.generateIssueKey(fileResult.filePath, message.line, message.ruleId); // Skip ignored issues unless requested if (!includeIgnored && ignoredSet.has(key)) { continue; } if (!issuesByRule.has(message.ruleId)) { issuesByRule.set(message.ruleId, { severity: message.severity === 2 ? 'error' : 'warning', fixable: !!message.fix, occurrences: [], }); } const rule = issuesByRule.get(message.ruleId); rule.occurrences.push({ file: fileResult.filePath, line: message.line, column: message.column, message: message.message, }); } } // Group occurrences by file const issues = Array.from(issuesByRule.entries()).map(([ruleId, data]) => { const fileMap = new Map(); for (const occ of data.occurrences) { if (!fileMap.has(occ.file)) { fileMap.set(occ.file, []); } fileMap.get(occ.file).push({ line: occ.line, column: occ.column, message: occ.message, }); } return { severity: data.severity, ruleId, count: data.occurrences.length, fixable: data.fixable, files: Array.from(fileMap.entries()).map(([path, locations]) => ({ path, locations, })), }; }); // Sort by count (most common first) issues.sort((a, b) => b.count - a.count); // Generate auto-fix suggestions const autoFixSuggestions = issues .filter((issue) => issue.fixable) .map((issue) => ({ ruleId: issue.ruleId, count: issue.count, impact: this.estimateFixImpact(issue.count, issue.severity), })) .sort((a, b) => { const impactOrder = { high: 3, medium: 2, low: 1 }; return impactOrder[b.impact] - impactOrder[a.impact]; }); // Detect new issues const newIssues = this.detectNewIssues(result); const originalSize = this.estimateOriginalOutputSize(result); const compactSize = this.estimateCompactSize(result); return { summary: { totalFiles: result.results.length, errorCount: result.errorCount, warningCount: result.warningCount, fixableCount: result.fixableErrorCount + result.fixableWarningCount, newIssuesCount: newIssues.length, fromCache, }, issues, autoFixSuggestions, newIssues, _metrics: { originalTokens: Math.ceil(originalSize / 4), compactedTokens: Math.ceil(compactSize / 4), reductionPercentage: Math.round(((originalSize - compactSize) / originalSize) * 100), }, }; } /** * Estimate fix impact */ estimateFixImpact(count, severity) { if (severity === 'error' || count > 20) { return 'high'; } if (count > 5) { return 'medium'; } return 'low'; } /** * Format cached output */ formatCachedOutput(result, onlyNew) { return this.transformOutput(result, onlyNew, false, true); } /** * Estimate original output size (full eslint output) */ estimateOriginalOutputSize(result) { // Estimate: each message is ~150 chars in full output const messageCount = result.results.reduce((sum, r) => sum + r.messages.length, 0); return messageCount * 150 + 500; // Plus header/footer } /** * Estimate compact output size */ estimateCompactSize(result) { const summary = { errorCount: result.errorCount, warningCount: result.warningCount, }; // Only include top 10 rules const topRules = result.results .flatMap((r) => r.messages) .slice(0, 10) .map((m) => ({ ruleId: m.ruleId, message: m.message })); return JSON.stringify({ summary, topRules }).length; } /** * Close cache connection */ close() { this.cache.close(); } } /** * Factory function for creating SmartLint with shared resources (for benchmarks) */ export function getSmartLintTool(cache, tokenCounter, metrics, projectRoot) { return new SmartLint(cache, tokenCounter, metrics, projectRoot); } /** * CLI-friendly function for running smart lint */ export async function runSmartLint(options = {}) { const cacheDir = join(homedir(), '.hypercontext', 'cache'); const cache = new CacheEngine(cacheDir, 100); const tokenCounter = new TokenCounter(); const metrics = new MetricsCollector(); const smartLint = new SmartLint(cache, tokenCounter, metrics, options.projectRoot); try { const result = await smartLint.run(options); let output = `\n🔍 Smart Lint Results ${result.summary.fromCache ? '(cached)' : ''}\n`; output += `${'='.repeat(50)}\n\n`; // Summary output += `Summary:\n`; output += ` Files: ${result.summary.totalFiles}\n`; output += ` Errors: ${result.summary.errorCount}\n`; output += ` Warnings: ${result.summary.warningCount}\n`; output += ` Fixable: ${result.summary.fixableCount}\n`; if (result.summary.newIssuesCount > 0) { output += ` New Issues: ${result.summary.newIssuesCount}\n`; } output += '\n'; // Issues by rule (top 10) if (result.issues.length > 0) { output += `Issues by Rule:\n`; for (const issue of result.issues.slice(0, 10)) { const icon = issue.severity === 'error' ? '✗' : '⚠'; const fixIcon = issue.fixable ? '🔧' : ''; output += ` ${icon} ${issue.ruleId} (${issue.count}) ${fixIcon}\n`; // Show first file as example if (issue.files.length > 0) { const firstFile = issue.files[0]; const firstLoc = firstFile.locations[0]; output += ` ${firstFile.path}:${firstLoc.line}:${firstLoc.column}\n`; output += ` ${firstLoc.message}\n`; if (issue.files.length > 1 || firstFile.locations.length > 1) { const totalOccurrences = issue.files.reduce((sum, f) => sum + f.locations.length, 0); output += ` ... ${totalOccurrences - 1} more occurrences\n`; } } } if (result.issues.length > 10) { output += ` ... and ${result.issues.length - 10} more rules\n`; } output += '\n'; } // Auto-fix suggestions if (result.autoFixSuggestions.length > 0) { output += `Auto-Fix Suggestions:\n`; for (const suggestion of result.autoFixSuggestions.slice(0, 5)) { const icon = suggestion.impact === 'high' ? '🔴' : suggestion.impact === 'medium' ? '🟡' : '🟢'; output += ` ${icon} ${suggestion.ruleId} (${suggestion.count} occurrences)\n`; output += ` Run: npx eslint --fix --rule "${suggestion.ruleId}"\n`; } output += '\n'; } // New issues if (result.newIssues.length > 0) { output += `New Issues:\n`; for (const issue of result.newIssues.slice(0, 5)) { output += ` • ${issue.file}:${issue.location}\n`; output += ` [${issue.ruleId}] ${issue.message}\n`; } if (result.newIssues.length > 5) { output += ` ... and ${result.newIssues.length - 5} more\n`; } output += '\n'; } // _metrics output += `Token Reduction:\n`; output += ` Original: ${result._metrics.originalTokens} tokens\n`; output += ` Compacted: ${result._metrics.compactedTokens} tokens\n`; output += ` Reduction: ${result._metrics.reductionPercentage}%\n`; return output; } finally { smartLint.close(); } } // MCP Tool definition export const SMART_LINT_TOOL_DEFINITION = { name: 'smart_lint', description: 'Run ESLint with intelligent caching, incremental analysis, and auto-fix suggestions', inputSchema: { type: 'object', properties: { files: { type: ['string', 'array'], description: 'Files or pattern to lint', items: { type: 'string' }, }, force: { type: 'boolean', description: 'Force full lint (ignore cache)', default: false, }, fix: { type: 'boolean', description: 'Auto-fix issues', default: false, }, projectRoot: { type: 'string', description: 'Project root directory', }, onlyNew: { type: 'boolean', description: 'Show only new issues since last run', default: false, }, includeIgnored: { type: 'boolean', description: 'Include previously ignored issues', default: false, }, maxCacheAge: { type: 'number', description: 'Maximum cache age in seconds (default: 3600)', default: 3600, }, }, }, }; //# sourceMappingURL=smart-lint.js.map