diff --git a/.gitignore b/.gitignore index bce9863..76bbbb9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,9 @@ dist/ # Evaluation results .evaluated-commits/ + +# Test temp files +.test-temp/ results.json report.html index.html @@ -13,6 +16,12 @@ index.html # Claude Code configuration .claude/ +#Cursor +.Cursor + +#Tests +coverage + # IDE configuration .vscode/* !.vscode/extensions.json @@ -28,3 +37,15 @@ index.html .archdoc.config.json .idea + + +# Using only for testing for now +.venv-semgrep +.qlty +!.qlty/ +.qlty/* +!.qlty/qlty.toml +!.qlty/.gitignore + +#Serena - MCP +.serena/ diff --git a/.qlty/.gitignore b/.qlty/.gitignore new file mode 100644 index 0000000..3036618 --- /dev/null +++ b/.qlty/.gitignore @@ -0,0 +1,7 @@ +* +!configs +!configs/** +!hooks +!hooks/** +!qlty.toml +!.gitignore diff --git a/.qlty/qlty.toml b/.qlty/qlty.toml new file mode 100644 index 0000000..670aeb9 --- /dev/null +++ b/.qlty/qlty.toml @@ -0,0 +1,97 @@ +# This file was automatically generated by `qlty init`. +# You can modify it to suit your needs. +# We recommend you to commit this file to your repository. +# +# This configuration is used by both Qlty CLI and Qlty Cloud. +# +# Qlty CLI -- Code quality toolkit for developers +# Qlty Cloud -- Fully automated Code Health Platform +# +# Try Qlty Cloud: https://qlty.sh +# +# For a guide to configuration, visit https://qlty.sh/d/config +# Or for a full reference, visit https://qlty.sh/d/qlty-toml +config_version = "0" + +exclude_patterns = [ + "*_min.*", + "*-min.*", + "*.min.*", + "**/.yarn/**", + "**/*.d.ts", + "**/assets/**", + "**/bower_components/**", + "**/build/**", + "**/cache/**", + "**/config/**", + "**/db/**", + "**/deps/**", + "**/dist/**", + "**/extern/**", + "**/external/**", + "**/generated/**", + "**/Godeps/**", + "**/gradlew/**", + "**/mvnw/**", + "**/node_modules/**", + "**/protos/**", + "**/seed/**", + "**/target/**", + "**/templates/**", + "**/testdata/**", + "**/vendor/**", +] + +test_patterns = [ + "**/test/**", + "**/spec/**", + "**/*.test.*", + "**/*.spec.*", + "**/*_test.*", + "**/*_spec.*", + "**/test_*.*", + "**/spec_*.*", +] + +[smells] +mode = "comment" + +[[source]] +name = "default" +default = true + + +[[plugin]] +name = "actionlint" + +[[plugin]] +name = "eslint" +version = "8.57.1" +package_file = "package.json" +package_filters = ["eslint", "prettier"] + +[[plugin]] +name = "osv-scanner" + +[[plugin]] +name = "prettier" +version = "3.7.3" +package_file = "package.json" +package_filters = ["prettier"] + +[[plugin]] +name = "ripgrep" +mode = "comment" + +[[plugin]] +name = "trivy" +drivers = [ + "config", + "fs-vuln", +] + +[[plugin]] +name = "trufflehog" + +[[plugin]] +name = "zizmor" diff --git a/README.md b/README.md index 18c5729..690acf6 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ CodeWave is a sophisticated Node.js CLI tool that leverages multiple AI agents i - **🧠 RAG (Retrieval-Augmented Generation)**: Automatic handling of large diffs (>100KB) using vector storage and semantic search - **πŸ”Œ Multi-LLM Support**: Works with Anthropic Claude, OpenAI GPT, and Google Gemini - **πŸ–₯️ Local LLM Support (Ollama)**: Run free, local models like **Llama 3**, **Mistral**, or **Gemma 2** via Ollama β€” no API key or internet required +- **πŸ”¬ Static Analysis**: Semgrep + Qlty scan changed lines, route findings by category to the right agents, and degrade gracefully when tools aren't installed - **⚑ Production-Ready**: LangGraph-based state machines with comprehensive error handling - **πŸ’Ύ JSON Output**: Structured results for programmatic access and CI/CD integration - **🎯 Zero Configuration**: Interactive setup wizard with sensible defaults @@ -92,6 +93,7 @@ This launches an interactive wizard to configure: - **LLM Provider**: Choose Anthropic Claude, OpenAI, Google Gemini, Groq or Ollama/LM Studio(local free models) - **API Keys**: Set your LLM provider credentials - **Model Selection**: Pick your preferred model (defaults recommended) +- **Static Analyzers**: Optionally install Semgrep + Qlty during setup (findings are scoped to changed lines and routed to agents by category) - **Default Settings**: Configure batch size, output directory, and reporting preferences Configuration is stored securely and only needs to be done once. @@ -447,7 +449,7 @@ CodeWave uses a 3-tier configuration system with priority order: ### Quick Setup -On first run, use `codewave config --init` to set up your LLM provider: +On first run, use `codewave config --init` to set up your LLM provider and optional static analyzer installation: ```bash codewave config --init @@ -1062,6 +1064,152 @@ codewave evaluate --commit HEAD --- +## MCP Integration with Claude Desktop + +CodeWave is available as an **MCP (Model Context Protocol) server**, allowing you to evaluate commits and analyze code quality directly within **Claude Desktop** through natural conversation. + +### What You Can Do + +- **Evaluate commits conversationally**: Ask Claude to evaluate your latest commits +- **Get insights without commands**: No need to remember CLI syntax +- **Generate OKRs**: Create data-driven development goals based on commit history +- **Compare quality**: Track improvements between commits +- **Analyze teams**: Get team-wide code quality statistics +- **Export reports**: Save evaluations to HTML/Markdown/JSON formats + +### Quick Start + +```bash +# 1. Install CodeWave globally +npm install -g @techdebtgpt/codewave + +# 2. Configure CodeWave +codewave config --init + +# 3. Setup Claude Desktop +# See docs/MCP_SETUP.md for detailed instructions +``` + +### Example Conversation + +``` +You: "Evaluate my last commit and tell me if there are any concerns" + +Claude: [Uses CodeWave MCP to evaluate] + "I've evaluated commit abc123. Overall score: 8.5/10. + + The code quality is excellent (9.0/10), but there are two concerns: + 1. Missing rate limiting on the login endpoint + 2. Password validation could be stronger + + I recommend adding rate limiting before pushing to production..." +``` + +### Available Tools + +- **10 production-ready tools** for evaluation, analytics, and reporting +- **Support for inline configuration** (no config file needed) +- **Three analysis modes**: fast, normal, and deep +- **Batch operations**: Evaluate up to 10 commits at once +- **No API calls** for analytics tools (instant results) + +### Key Features + +| Feature | Description | +|---------|-------------| +| Natural language interface | Chat with Claude instead of remembering commands | +| Conversational workflows | Combine multiple tools in one conversation | +| Context awareness | Claude remembers previous results in the conversation | +| Intelligent interpretation | Claude explains results in plain language | +| Follow-up questions | Ask for clarification or deeper analysis | + +### Available MCP Tools + +| Tool | Purpose | Makes API Calls? | +|------|---------|------------------| +| `evaluate_commit` | Evaluate single commit | Yes | +| `batch_evaluate` | Evaluate multiple commits | Yes | +| `generate_okr` | Generate developer OKRs | Yes | +| `check_config` | Verify configuration | No | +| `list_evaluations` | Browse evaluation history | No | +| `get_report` | Get detailed commit report | No | +| `get_author_stats` | Analyze developer stats | No | +| `compare_commits` | Compare two commits | No | +| `get_team_stats` | Team-wide statistics | No | +| `export_report` | Export to file | No | + +### Documentation + +- **[MCP Setup Guide](./docs/MCP_SETUP.md)** - Installation and Claude Desktop configuration +- **[MCP Tools Reference](./docs/MCP_TOOLS_REFERENCE.md)** - Complete tool documentation with examples +- **[MCP Examples](./docs/MCP_EXAMPLES.md)** - Practical workflows and example conversations + +### Requirements + +- **Claude Desktop** app installed ([download](https://claude.ai/download)) +- **Node.js** β‰₯18.0.0 +- **CodeWave** globally installed +- **Valid configuration** with LLM API key + +### How It Works + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Claude Desktop β”‚ +β”‚ (Your Chat) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ Natural language request + β”‚ "Evaluate my last commit" + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ MCP Protocol β”‚ +β”‚ (JSON-RPC) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ Tool call: evaluate_commit + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ CodeWave Server β”‚ +β”‚ Multi-Agent AI β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ Results + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Claude Desktop β”‚ +β”‚ (Interprets & β”‚ +β”‚ Explains) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +### Getting Started + +1. **Complete setup**: Follow the [MCP Setup Guide](./docs/MCP_SETUP.md) +2. **Try examples**: See [MCP Examples](./docs/MCP_EXAMPLES.md) for workflows +3. **Learn tools**: Reference [MCP Tools Reference](./docs/MCP_TOOLS_REFERENCE.md) as needed + +### MCP vs CLI + +Both interfaces use the same CodeWave engine. Choose based on your workflow: + +| Use Case | Recommended Interface | +|----------|----------------------| +| Interactive exploration | **MCP (Claude Desktop)** | +| Quick quality checks | **MCP (Claude Desktop)** | +| Learning patterns | **MCP (Claude Desktop)** | +| Performance reviews | **MCP (Claude Desktop)** | +| CI/CD automation | **CLI** | +| Scripting/batch jobs | **CLI** | +| Custom integrations | **CLI + API** | + +See [docs/MCP_SETUP.md](./docs/MCP_SETUP.md) for complete setup instructions. + +--- + ## Examples ### Example 1: Evaluate Latest 5 Commits diff --git a/cli/commands/__tests__/config.command.test.ts b/cli/commands/__tests__/config.command.test.ts new file mode 100644 index 0000000..de410f4 --- /dev/null +++ b/cli/commands/__tests__/config.command.test.ts @@ -0,0 +1,111 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import inquirer from 'inquirer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ToolInstaller } from '../../../src/services/static-analysis/tool-installer'; +import { runConfigCommand } from '../config.command'; + +type PromptAnswers = Record; + +function mockPromptWithAnswers(answers: PromptAnswers): void { + vi.spyOn(inquirer, 'prompt').mockImplementation(async (questions: any) => { + const prompts = Array.isArray(questions) ? questions : [questions]; + const result: Record = {}; + + for (const prompt of prompts) { + const promptName = prompt?.name; + if (!promptName) { + continue; + } + + if (Object.prototype.hasOwnProperty.call(answers, promptName)) { + result[promptName] = answers[promptName]; + continue; + } + + if (prompt.type === 'confirm') { + result[promptName] = prompt.default ?? false; + continue; + } + + if (prompt.type === 'list') { + result[promptName] = prompt.default ?? prompt.choices?.[0]?.value ?? ''; + continue; + } + + result[promptName] = ''; + } + + return result; + }); +} + +describe('config command static analyzer setup', () => { + let tempRoot: string; + let previousCwd: string; + + beforeEach(() => { + previousCwd = process.cwd(); + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codewave-config-command-')); + process.chdir(tempRoot); + + vi.spyOn(console, 'log').mockImplementation(() => undefined); + vi.spyOn(ToolInstaller, 'ensureAvailable').mockResolvedValue({ + semgrep: { + available: true, + executable: '/usr/bin/semgrep', + installedNow: false, + }, + qlty: { + available: true, + executable: '/usr/bin/qlty', + installedNow: false, + }, + }); + }); + + afterEach(() => { + process.chdir(previousCwd); + fs.rmSync(tempRoot, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('enables static analysis and runs installer during init when user selects yes', async () => { + mockPromptWithAnswers({ + provider: 'openai', + selectedModel: 'gpt-4o-mini', + apiKey: 'sk-test-key', + enableTracing: false, + installStaticAnalyzers: true, + }); + + await runConfigCommand(['--init']); + + const configPath = path.join(tempRoot, '.codewave.config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + + expect(ToolInstaller.ensureAvailable).toHaveBeenCalledWith(true, false); + expect(config.staticAnalysis.enabled).toBe(true); + expect(config.staticAnalysis.autoInstall).toBe(false); + }); + + it('disables static analysis and skips installer during init when user selects no', async () => { + mockPromptWithAnswers({ + provider: 'openai', + selectedModel: 'gpt-4o-mini', + apiKey: 'sk-test-key', + enableTracing: false, + installStaticAnalyzers: false, + }); + + await runConfigCommand(['--init']); + + const configPath = path.join(tempRoot, '.codewave.config.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + + expect(ToolInstaller.ensureAvailable).not.toHaveBeenCalled(); + expect(config.staticAnalysis.enabled).toBe(false); + expect(config.staticAnalysis.autoInstall).toBe(false); + }); +}); diff --git a/cli/commands/batch-evaluate-command.ts b/cli/commands/batch-evaluate-command.ts index db0e7ca..08dcbc9 100644 --- a/cli/commands/batch-evaluate-command.ts +++ b/cli/commands/batch-evaluate-command.ts @@ -320,10 +320,12 @@ async function evaluateCommit( commitHash: commit.hash, commitIndex: index + 1, totalCommits: 1, // Not used in batch context + repoPath: options.repository, }, { streaming: options.streaming, disableTracing: true, + outputMode: 'batch', onProgress: (state: any) => { if (state.type === 'vectorizing') { vectorChunks = state.total; @@ -386,6 +388,7 @@ async function evaluateCommit( metadata, diff, developerOverview: evaluationResult.developerOverview, + staticAnalysis: evaluationResult.staticAnalysis, }); const metrics = calculateAggregateMetrics(agentResults); diff --git a/cli/commands/config.command.ts b/cli/commands/config.command.ts index e294bfc..25a780a 100644 --- a/cli/commands/config.command.ts +++ b/cli/commands/config.command.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import inquirer from 'inquirer'; import chalk from 'chalk'; +import { ToolInstaller } from '../../src/services/static-analysis/tool-installer'; const CONFIG_FILE = '.codewave.config.json'; @@ -47,7 +48,28 @@ const DEFAULT_CONFIG = { enabled: false, apiKey: '', project: 'codewave', - endpoint: 'https://api.smith.langchain.com', + endpoint: 'https://eu.api.smith.langchain.com', + }, + staticAnalysis: { + enabled: true, + autoInstall: false, + autoInstallSystemDeps: false, + semgrep: { + enabled: true, + rules: ['auto'], + timeout: 120000, + }, + qlty: { + enabled: true, + timeout: 60000, + smells: { + enabled: true, + timeout: 60000, + includeDuplication: true, + }, + }, + maxFindings: 500, + minSeverity: 'warning', }, }; @@ -63,6 +85,25 @@ function findConfigPath(): string | null { return null; } +interface StaticAnalyzerToolStatus { + available: boolean; + executable: string | null; + installedNow: boolean; + error?: string; +} + +function formatStaticAnalyzerStatus(toolName: string, status: StaticAnalyzerToolStatus): string { + if (status.available && status.executable) { + return `${toolName}: ${status.installedNow ? 'installed now' : 'detected'} (${status.executable})`; + } + + if (status.error) { + return `${toolName}: unavailable (${status.error})`; + } + + return `${toolName}: unavailable`; +} + /** * Initialize config with interactive prompts */ @@ -118,6 +159,25 @@ async function initializeConfig(): Promise { if (existingConfig.tracing) { config.tracing = { ...config.tracing, ...existingConfig.tracing }; } + if (existingConfig.staticAnalysis) { + const { excludedPaths: _drop, ...existingStaticAnalysis } = existingConfig.staticAnalysis; + config.staticAnalysis = { + ...config.staticAnalysis, + ...existingStaticAnalysis, + semgrep: { + ...config.staticAnalysis.semgrep, + ...(existingConfig.staticAnalysis.semgrep || {}), + }, + qlty: { + ...config.staticAnalysis.qlty, + ...(existingConfig.staticAnalysis.qlty || {}), + smells: { + ...config.staticAnalysis.qlty.smells, + ...(existingConfig.staticAnalysis.qlty?.smells || {}), + }, + }, + }; + } } // Interactive API key setup - MANDATORY @@ -406,6 +466,56 @@ async function initializeConfig(): Promise { ); } + // Static analysis setup (optional) + console.log(chalk.cyan('\n\nπŸ›‘οΈ Static Analysis Setup (OPTIONAL)\n')); + const defaultInstallStaticAnalyzers = config.staticAnalysis.enabled === true; + const { installStaticAnalyzers } = await inquirer.prompt([ + { + type: 'confirm', + name: 'installStaticAnalyzers', + message: 'Install static analyzers (Semgrep + Qlty) for better commit evaluation?', + default: defaultInstallStaticAnalyzers, + }, + ]); + + let staticAnalysisSummary = 'Disabled'; + if (installStaticAnalyzers) { + config.staticAnalysis.enabled = true; + config.staticAnalysis.autoInstall = false; + + console.log(chalk.gray('\nChecking/installing Semgrep and Qlty...')); + try { + const availability = await ToolInstaller.ensureAvailable( + true, + config.staticAnalysis.autoInstallSystemDeps + ); + console.log(chalk.gray(` β€’ ${formatStaticAnalyzerStatus('Semgrep', availability.semgrep)}`)); + console.log(chalk.gray(` β€’ ${formatStaticAnalyzerStatus('Qlty', availability.qlty)}`)); + + staticAnalysisSummary = `Enabled (semgrep: ${ + availability.semgrep.available ? 'ready' : 'missing' + }, qlty: ${availability.qlty.available ? 'ready' : 'missing'})`; + } catch (error) { + console.log( + chalk.yellow( + `⚠ Static analyzer setup encountered an error: ${ + error instanceof Error ? error.message : String(error) + }` + ) + ); + staticAnalysisSummary = 'Enabled (installation check failed)'; + } + } else { + config.staticAnalysis.enabled = false; + config.staticAnalysis.autoInstall = false; + staticAnalysisSummary = 'Disabled'; + console.log( + chalk.gray( + " Static analysis disabled. Re-run 'codewave config --init' and choose 'Yes' to enable it." + ) + ); + } + // Save configuration fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); console.log(chalk.green(`\nβœ… Created ${path.relative(process.cwd(), configPath)}`)); @@ -463,6 +573,7 @@ async function initializeConfig(): Promise { console.log(chalk.gray(` β€’ Config file: ${path.relative(process.cwd(), configPath)}`)); console.log(chalk.gray(` β€’ LLM Provider: ${config.llm.provider} (${config.llm.model})`)); console.log(chalk.gray(` β€’ Tracing: ${config.tracing.enabled ? 'Enabled' : 'Disabled'}`)); + console.log(chalk.gray(` β€’ Static Analysis: ${staticAnalysisSummary}`)); console.log(chalk.cyan('\nπŸ’‘ Tips:')); console.log(chalk.gray(' β€’ Change provider: codewave config --set llm.provider=openai')); console.log(chalk.gray(' β€’ Change model: codewave config --set llm.model=gpt-4o')); diff --git a/cli/commands/evaluate-command.ts b/cli/commands/evaluate-command.ts index 21d2223..81532f9 100644 --- a/cli/commands/evaluate-command.ts +++ b/cli/commands/evaluate-command.ts @@ -19,6 +19,7 @@ import { getDiffFromStaged, getDiffFromCurrent, extractCommitHash, + extractFilesFromDiff, generateDiffHash, } from '../utils/git-utils'; @@ -163,13 +164,28 @@ export async function runEvaluateCommand(args: string[]) { console.log(chalk.gray(`πŸ“„ Source: ${sourceDescription}`)); console.log(chalk.gray(`🎯 Depth: ${depthMode}\n`)); + let resolvedCommitSha: string | undefined; + if (source === 'commit' && sourceDescription) { + const revParseResult = spawnSync('git', ['rev-parse', sourceDescription], { + cwd: repoPath, + encoding: 'utf-8', + }); + + if (revParseResult.status === 0 && revParseResult.stdout.trim().length > 0) { + resolvedCommitSha = revParseResult.stdout.trim(); + } + } + // Extract commit hash before evaluation for logging let commitHash = extractCommitHash(diff); - if (source === 'commit' && sourceDescription) { - commitHash = sourceDescription.substring(0, 8); // Ensure 8 chars for consistency + if (source === 'commit' && resolvedCommitSha) { + commitHash = resolvedCommitSha.substring(0, 8); // Use deterministic SHA-based directory key + } else if (source === 'commit' && sourceDescription) { + commitHash = sourceDescription.substring(0, 8); } else if (!commitHash) { commitHash = generateDiffHash(diff); } + const filesChanged = extractFilesFromDiff(diff); // Create agent registry with all agents const agentRegistry = createAgentRegistry(config); @@ -177,8 +193,9 @@ export async function runEvaluateCommand(args: string[]) { const orchestrator = new CommitEvaluationOrchestrator(agentRegistry, config); const context = { commitDiff: diff, - filesChanged: [], + filesChanged, commitHash, // Add commit hash to context for logging + repoPath, config, }; @@ -313,6 +330,7 @@ export async function runEvaluateCommand(args: string[]) { const evaluationResult = await orchestrator.evaluateCommit(context, { streaming: streamingEnabled, threadId: `eval-${Date.now()}`, + outputMode: 'single', onProgress: (state: any) => { // For single evaluate with LangSmith (no streaming), onProgress is called once at end // Just track final state for summary @@ -334,13 +352,13 @@ export async function runEvaluateCommand(args: string[]) { let fullCommitHash: string | undefined; // Store full hash for metadata if (source === 'commit' && sourceDescription) { - // Use sourceDescription for fetching metadata - fullCommitHash = sourceDescription; + // Prefer resolved SHA so HEAD maps to stable commit identity. + fullCommitHash = resolvedCommitSha || sourceDescription; // Get commit metadata const showResult = spawnSync( 'git', - ['show', '--no-patch', '--format=%an|||%s|||%aI', sourceDescription], + ['show', '--no-patch', '--format=%an|||%s|||%aI', fullCommitHash], { cwd: repoPath, encoding: 'utf-8', @@ -379,6 +397,7 @@ export async function runEvaluateCommand(args: string[]) { metadata, diff, developerOverview: evaluationResult.developerOverview, + staticAnalysis: evaluationResult.staticAnalysis, }); // Print completion message using shared function diff --git a/cli/index.ts b/cli/index.ts index 4f9096b..4c043c2 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -23,6 +23,7 @@ import { runBatchEvaluateCommand } from './commands/batch-evaluate-command'; import { runGenerateOkrCommand } from './commands/generate-okr-command'; import * as path from 'path'; import * as fs from 'fs'; +import { ConfigValidationError, ConfigValidationIssue } from '../src/config/config-validator'; async function main() { const [, , command, ...args] = process.argv; @@ -65,11 +66,45 @@ async function main() { process.exit(1); } } catch (err) { + if (err instanceof ConfigValidationError) { + printConfigValidationError(err); + process.exit(1); + } + console.error('Error:', err instanceof Error ? err.message : err); process.exit(1); } } +function printConfigValidationError(error: ConfigValidationError) { + console.error('❌ Invalid configuration: staticAnalysis contains invalid values'); + for (const issue of error.issues) { + console.error(formatConfigValidationIssue(issue)); + } +} + +function formatConfigValidationIssue(issue: ConfigValidationIssue): string { + const formattedValue = formatIssueValue(issue.value); + const valueSuffix = formattedValue ? ` (received: ${formattedValue})` : ''; + return ` - [${issue.code}] ${issue.path}: ${issue.message}${valueSuffix}`; +} + +function formatIssueValue(value: unknown): string { + if (value === undefined) { + return ''; + } + + if (typeof value === 'string') { + return `"${value}"`; + } + + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + function printUsage() { console.log('╔═══════════════════════════════════════════════════════════════╗'); console.log('β•‘ 🌊 CodeWave CLI β•‘'); @@ -137,6 +172,11 @@ function printUsage() { } main().catch((error) => { + if (error instanceof ConfigValidationError) { + printConfigValidationError(error); + process.exit(1); + } + console.error('❌ Fatal error:', error instanceof Error ? error.message : String(error)); process.exit(1); }); diff --git a/cli/mcp-server.ts b/cli/mcp-server.ts new file mode 100644 index 0000000..3b7b4d4 --- /dev/null +++ b/cli/mcp-server.ts @@ -0,0 +1,22 @@ +#!/usr/bin/env node + +/** + * CLI Entry Point for CodeWave MCP Server + * + * Usage: + * npx codewave-mcp + * node dist/cli/mcp-server.js + */ + +import { startServer } from '../src/mcp-server/index'; +import { LoggerService } from '../src/common/logger'; +import { SilentTransport } from '../src/common/logger/transports/silent.transport'; + +// Suppress LoggerService output in MCP mode β€” stdout must stay clean for JSON-RPC +LoggerService.setTransport(new SilentTransport()); + +// Start the MCP server +startServer().catch((error) => { + console.error('Failed to start CodeWave MCP server:', error); + process.exit(1); +}); diff --git a/cli/utils/__tests__/git-utils.test.ts b/cli/utils/__tests__/git-utils.test.ts new file mode 100644 index 0000000..ab16ecc --- /dev/null +++ b/cli/utils/__tests__/git-utils.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { extractFilesFromDiff } from '../git-utils'; + +describe('extractFilesFromDiff', () => { + it('captures the b/ path for renamed files', () => { + const diff = `diff --git a/src/old-name.ts b/src/new-name.ts`; + expect(extractFilesFromDiff(diff)).toEqual(['src/new-name.ts']); + }); + + it('parses quoted git paths with spaces', () => { + const diff = `diff --git "a/src/old name.ts" "b/src/new name.ts"`; + expect(extractFilesFromDiff(diff)).toEqual(['src/new name.ts']); + }); + + it('parses quoted git paths with escaped quotes', () => { + const diff = String.raw`diff --git "a/src/old\"name.ts" "b/src/new\"name.ts"`; + expect(extractFilesFromDiff(diff)).toEqual(['src/new"name.ts']); + }); + + it('decodes C-escaped UTF-8 octal sequences in quoted paths', () => { + const diff = String.raw`diff --git "a/src/old\303\251.ts" "b/src/new\303\251.ts"`; + expect(extractFilesFromDiff(diff)).toEqual(['src/newΓ©.ts']); + }); +}); diff --git a/cli/utils/__tests__/shared.utils.test.ts b/cli/utils/__tests__/shared.utils.test.ts new file mode 100644 index 0000000..751cab8 --- /dev/null +++ b/cli/utils/__tests__/shared.utils.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { formatRawOutputForArtifact } from '../shared.utils'; + +describe('formatRawOutputForArtifact', () => { + it('parses JSON stdout into a structured object', () => { + const formatted = formatRawOutputForArtifact({ + stdout: '{"version":"1.0.0","results":[{"id":"x"}]}', + stderr: 'warning line', + exitCode: 0, + timedOut: false, + }); + + expect(formatted.stdout.format).toBe('json'); + expect(formatted.stdout.value).toEqual({ + version: '1.0.0', + results: [{ id: 'x' }], + }); + expect(formatted.stderr.format).toBe('text'); + expect(formatted.stderr.value).toBe('warning line'); + expect(formatted.stderr.lines).toEqual(['warning line']); + }); + + it('parses JSON payloads even when stdout has surrounding whitespace', () => { + const formatted = formatRawOutputForArtifact({ + stdout: '\n {"runs":[{"results":[{"ruleId":"x"}]}]} \n', + stderr: '', + exitCode: 1, + timedOut: false, + }); + + expect(formatted.stdout.format).toBe('json'); + expect(formatted.stdout.value).toEqual({ + runs: [{ results: [{ ruleId: 'x' }] }], + }); + expect(formatted.exitCode).toBe(1); + expect(formatted.timedOut).toBe(false); + }); + + it('extracts JSON from mixed output with leading text', () => { + const formatted = formatRawOutputForArtifact({ + stdout: 'note: tool warning before payload\n{"results":[{"id":"x"}]}', + stderr: '', + exitCode: 0, + timedOut: false, + }); + + expect(formatted.stdout.format).toBe('json'); + expect(formatted.stdout.value).toEqual({ + results: [{ id: 'x' }], + }); + }); + + it('keeps non-JSON stdout as text', () => { + const formatted = formatRawOutputForArtifact({ + stdout: 'plain text output', + stderr: '{"message":"structured stderr"}', + exitCode: null, + timedOut: true, + }); + + expect(formatted.stdout.format).toBe('text'); + expect(formatted.stdout.value).toBe('plain text output'); + expect(formatted.stdout.lines).toEqual(['plain text output']); + expect(formatted.stderr.format).toBe('json'); + expect(formatted.stderr.value).toEqual({ message: 'structured stderr' }); + }); +}); diff --git a/cli/utils/git-utils.ts b/cli/utils/git-utils.ts index c62cd98..450286f 100644 --- a/cli/utils/git-utils.ts +++ b/cli/utils/git-utils.ts @@ -83,17 +83,77 @@ export function extractFilesFromDiff(diff: string): string[] { const lines = diff.split('\n'); for (const line of lines) { - if (line.startsWith('diff --git')) { - const match = line.match(/diff --git a\/(.+?) b\//); - if (match) { - files.push(match[1]); - } + if (!line.startsWith('diff --git ')) { + continue; + } + + // Git C-quotes paths with special characters or spaces: + // diff --git "a/old name.ts" "b/new name.ts" + const quotedMatch = line.match( + /^diff --git "a\/(?:[^"\\]|\\.)*" "b\/((?:[^"\\]|\\.)+)"$/ + ); + if (quotedMatch) { + files.push(unquoteGitPath(quotedMatch[1])); + continue; + } + + // Standard format β€” capture the b/ (new/renamed) path: + // diff --git a/old.ts b/new.ts + const unquotedMatch = line.match(/^diff --git a\/.+? b\/(.+)$/); + if (unquotedMatch) { + files.push(unquotedMatch[1]); } } return files; } +/** + * Decode git C-style path escaping: consecutive octal byte sequences + * (\303\251) for non-ASCII characters and simple escapes (\\, \n, \t, \"). + * Input is the raw content inside the surrounding quotes (quotes already stripped). + */ +function unquoteGitPath(escaped: string): string { + let result = ''; + const pendingBytes: number[] = []; + let i = 0; + + const flushBytes = () => { + if (pendingBytes.length > 0) { + result += Buffer.from(pendingBytes).toString('utf8'); + pendingBytes.length = 0; + } + }; + + while (i < escaped.length) { + if (escaped[i] !== '\\' || i + 1 >= escaped.length) { + flushBytes(); + result += escaped[i]; + i++; + continue; + } + + const next = escaped[i + 1]; + if ( + /^[0-7]$/.test(next) && + i + 3 < escaped.length && + /^[0-7]{2}$/.test(escaped.slice(i + 2, i + 4)) + ) { + pendingBytes.push(parseInt(escaped.slice(i + 1, i + 4), 8)); + i += 4; + continue; + } + + flushBytes(); + const escMap: Record = { '\\': '\\', n: '\n', t: '\t', r: '\r', '"': '"' }; + result += escMap[next] ?? next; + i += 2; + } + + flushBytes(); + return result; +} + /** * Extract commit hash from diff content */ diff --git a/cli/utils/shared.utils.ts b/cli/utils/shared.utils.ts index 7cbf768..4e76aa2 100644 --- a/cli/utils/shared.utils.ts +++ b/cli/utils/shared.utils.ts @@ -13,6 +13,7 @@ import { generateConversationTranscript } from '../../src/formatters/conversatio import { MetricsCalculationService } from '../../src/services/metrics-calculation.service'; import { AgentResult } from '../../src/agents/agent.interface'; import { TokenSnapshot, EvaluationHistoryEntry } from '../../src/types/output.types'; +import { RawToolOutput, StaticAnalysisSummary } from '../../src/services/static-analysis/types'; import fs from 'fs/promises'; import * as fsSync from 'fs'; import path from 'path'; @@ -106,6 +107,10 @@ export interface SaveReportsOptions { agentResults: AgentResult[]; metadata?: EvaluationMetadata; developerOverview?: string; + staticAnalysis?: { + summary: StaticAnalysisSummary; + rawOutputs: RawToolOutput; + }; } /** @@ -119,7 +124,14 @@ export interface SaveReportsOptions { * - history.json (tracks re-evaluations) */ export async function saveEvaluationReports(options: SaveReportsOptions): Promise { - const { outputDir, diff, agentResults, metadata = {}, developerOverview } = options; + const { + outputDir, + diff, + agentResults, + metadata = {}, + developerOverview, + staticAnalysis, + } = options; // Ensure output directory exists await fs.mkdir(outputDir, { recursive: true }); @@ -127,6 +139,25 @@ export async function saveEvaluationReports(options: SaveReportsOptions): Promis // Track evaluation history with metrics and tokens await trackEvaluationHistory(outputDir, metadata, agentResults); + let staticAnalysisSummaryForResults: Record | null = null; + if (staticAnalysis) { + const references = await writeStaticAnalysisArtifacts( + outputDir, + staticAnalysis.summary, + staticAnalysis.rawOutputs + ); + staticAnalysisSummaryForResults = { + available: staticAnalysis.summary.available, + counts: staticAnalysis.summary.counts, + timing: staticAnalysis.summary.timing, + truncated: staticAnalysis.summary.truncated, + totalBeforeCap: staticAnalysis.summary.totalBeforeCap, + skippedFiles: staticAnalysis.summary.skippedFiles, + warnings: staticAnalysis.summary.warnings, + files: references, + }; + } + // 1. Save results.json const resultsJson = { timestamp: metadata.timestamp || new Date().toISOString(), @@ -139,6 +170,7 @@ export async function saveEvaluationReports(options: SaveReportsOptions): Promis }, developerOverview: developerOverview || null, agents: agentResults, + staticAnalysisSummary: staticAnalysisSummaryForResults, }; await fs.writeFile(path.join(outputDir, 'results.json'), JSON.stringify(resultsJson, null, 2)); @@ -180,6 +212,174 @@ export async function saveEvaluationReports(options: SaveReportsOptions): Promis } } +async function writeStaticAnalysisArtifacts( + outputDir: string, + summary: StaticAnalysisSummary, + rawOutputs: RawToolOutput +): Promise<{ + summary: string; + findings: string; + rawSemgrep?: string; + rawQlty?: string; + rawQltySmells?: string; +}> { + const staticDir = path.join(outputDir, 'static-analysis'); + await fs.mkdir(staticDir, { recursive: true }); + + const { findings, ...summaryWithoutFindings } = summary; + await fs.writeFile( + path.join(staticDir, 'summary.json'), + JSON.stringify(summaryWithoutFindings, null, 2) + ); + await fs.writeFile(path.join(staticDir, 'findings.json'), JSON.stringify(findings, null, 2)); + + const references: { + summary: string; + findings: string; + rawSemgrep?: string; + rawQlty?: string; + rawQltySmells?: string; + } = { + summary: 'static-analysis/summary.json', + findings: 'static-analysis/findings.json', + }; + + if (rawOutputs.semgrep) { + await fs.writeFile( + path.join(staticDir, 'raw-semgrep.json'), + JSON.stringify(formatRawOutputForArtifact(rawOutputs.semgrep), null, 2) + ); + references.rawSemgrep = 'static-analysis/raw-semgrep.json'; + } + + if (rawOutputs.qlty) { + await fs.writeFile( + path.join(staticDir, 'raw-qlty.json'), + JSON.stringify(formatRawOutputForArtifact(rawOutputs.qlty), null, 2) + ); + references.rawQlty = 'static-analysis/raw-qlty.json'; + } + + if (rawOutputs.qltySmells) { + await fs.writeFile( + path.join(staticDir, 'raw-qlty-smells.json'), + JSON.stringify(formatRawOutputForArtifact(rawOutputs.qltySmells), null, 2) + ); + references.rawQltySmells = 'static-analysis/raw-qlty-smells.json'; + } + + return references; +} + +export function formatRawOutputForArtifact(rawOutput: { + stdout: string; + stderr: string; + exitCode: number | null; + timedOut: boolean; +}): { + stdout: { + format: 'json' | 'text'; + value: unknown; + lines?: string[]; + }; + stderr: { + format: 'json' | 'text'; + value: unknown; + lines?: string[]; + }; + exitCode: number | null; + timedOut: boolean; +} { + const parsedStdout = formatOutputStream(rawOutput.stdout); + const parsedStderr = formatOutputStream(rawOutput.stderr); + + return { + stdout: parsedStdout, + stderr: parsedStderr, + exitCode: rawOutput.exitCode, + timedOut: rawOutput.timedOut, + }; +} + +function formatOutputStream(value: string): { + format: 'json' | 'text'; + value: unknown; + lines?: string[]; +} { + const parsedJson = parseJsonIfPossible(value); + if (parsedJson !== null) { + return { format: 'json', value: parsedJson }; + } + + const lines = splitIntoReadableLines(value); + return { + format: 'text', + value, + lines, + }; +} + +function parseJsonIfPossible(value: string): unknown | null { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + try { + return JSON.parse(trimmed); + } catch { + // Fall through to mixed-output extraction. + } + + const candidates: string[] = []; + + const jsonFenceMatch = trimmed.match(/```json\s*([\s\S]*?)\s*```/i); + if (jsonFenceMatch?.[1]) { + candidates.push(jsonFenceMatch[1].trim()); + } + + const firstObject = trimmed.indexOf('{'); + const lastObject = trimmed.lastIndexOf('}'); + if (firstObject >= 0 && lastObject > firstObject) { + candidates.push(trimmed.slice(firstObject, lastObject + 1)); + } + + const firstArray = trimmed.indexOf('['); + const lastArray = trimmed.lastIndexOf(']'); + if (firstArray >= 0 && lastArray > firstArray) { + candidates.push(trimmed.slice(firstArray, lastArray + 1)); + } + + for (const candidate of candidates) { + try { + return JSON.parse(candidate); + } catch { + // Try next candidate. + } + } + + return null; +} + +function splitIntoReadableLines(value: string): string[] { + const sourceLines = value.split(/\r?\n/); + const wrappedLines: string[] = []; + const maxLineLength = 220; + + for (const line of sourceLines) { + if (line.length <= maxLineLength) { + wrappedLines.push(line); + continue; + } + + for (let i = 0; i < line.length; i += maxLineLength) { + wrappedLines.push(line.slice(i, i + maxLineLength)); + } + } + + return wrappedLines; +} + /** * Extract metrics from final round agent results (last 5 agents) * Extracts individual agent scores and applies weighted averaging based on agent expertise diff --git a/docs/ADVANCED_FEATURES.md b/docs/ADVANCED_FEATURES.md index 7fe5330..439898d 100644 --- a/docs/ADVANCED_FEATURES.md +++ b/docs/ADVANCED_FEATURES.md @@ -9,6 +9,7 @@ Deep dive into CodeWave's sophisticated analysis capabilities: Developer Overvie - [Convergence Detection](#convergence-detection) - Agent consensus measurement - [Multi-Round Agent Discussion](#multi-round-agent-discussion) - Structured agent collaboration - [Agent RAG Query Tracking](#agent-rag-query-tracking) - Query monitoring and verification +- [Static Analysis Integration](#static-analysis-integration) - Automated code scanning with agent routing - [Understanding Evaluation Depth](#understanding-evaluation-depth) - [Advanced Usage](#advanced-usage) @@ -1004,6 +1005,101 @@ This script: --- +## Static Analysis Integration + +CodeWave can optionally run **Semgrep** (SAST) and **Qlty** (linting/smells) on the files changed by a commit, then route the findings to the agents best qualified to interpret them. This supplements β€” but never replaces β€” agent judgment. + +### What It Does + +1. Scans only the files in the commit diff (test files are excluded automatically) +2. Filters findings to **changed lines only**, so pre-existing issues are not reported +3. Routes each finding to the right agent(s) based on its category +4. Provides a risk profile summary (counts, hotspot files, risk level) + +### Category Routing + +Each finding is classified into one of six categories. The routing table determines which agents see it: + +| Category | Primary Agent | Secondary Agents | +|----------|--------------|-----------------| +| `security` | Senior Architect | Business Analyst, Developer Reviewer | +| `quality` | Developer Reviewer | Developer Author | +| `complexity` | Senior Architect | Developer Author, Developer Reviewer | +| `duplication` | Senior Architect | Developer Reviewer | +| `style` | Developer Reviewer | _(none)_ | +| `bug` | Developer Reviewer | Developer Author, SDET | + +- **Primary** agents receive all severity levels (error, warning, info) +- **Secondary** agents receive only errors and warnings (info is filtered out) + +### Round Behavior + +- **Round 1**: Full findings are injected into each agent's prompt β€” risk profile + all relevant findings capped at 200 per agent +- **Round 2+**: A condensed reference replaces the full block β€” risk profile + error-severity findings only + +This keeps token usage manageable across multi-round discussions while ensuring critical issues remain visible. + +### Risk Level Interpretation + +The risk profile is computed from finding counts (no LLM call): + +| Condition | Risk Level | +|-----------|-----------| +| 3+ errors | **HIGH** | +| 1-2 errors | **MEDIUM** | +| 5+ warnings (no errors) | **MODERATE** | +| Otherwise | **LOW** | + +The profile also lists findings by category and the top-3 hotspot files (most findings). + +### When Tools Aren't Installed + +Static analysis degrades gracefully: + +- If Semgrep/Qlty aren't installed, the service logs a warning and evaluation proceeds without analysis findings +- Agents still evaluate using the diff, developer overview, and other context +- To install the tools: run `codewave config --init` and choose "Yes" for static analyzer installation +- Tools are installed once during setup and cached β€” they are never auto-installed at evaluation time + +### Finding Format in Agent Prompts + +Agents receive findings in a structured text block: + +``` +## Additional Context: Static Analysis Tool Results + +Automated tools (Semgrep, Qlty) scanned the changed code and produced the +findings below. These are provided as supplementary context β€” tools can +produce false positives, and context matters. Use your professional judgment +to determine their relevance and impact on your assessment. + +--- Risk Profile --- +Findings: 12 total (2 errors, 7 warnings, 3 info) +By category: security=3, quality=5, bug=4 +Hotspot files: src/auth.ts (5), src/db.ts (4), src/api.ts (3) +Risk: MEDIUM (2 error(s) detected) +--- + +### Findings Relevant to Your Expertise (8 of 12): +1. [ERROR][security] src/auth.ts:42 - SQL injection risk (semgrep:sql-injection) [CWE-89] +2. [WARNING][quality] src/db.ts:15 - Unused variable (qlty:no-unused-vars) [fixable] +... +``` + +### Configuration + +Static analysis is controlled by the `staticAnalysis` section in `.codewave.config.json`. See [CONFIGURATION.md](./CONFIGURATION.md#static-analysis-validation-fail-fast) for all options. + +Key settings: + +- `staticAnalysis.enabled` β€” Master toggle (default: `true`) +- `staticAnalysis.semgrep.enabled` / `staticAnalysis.qlty.enabled` β€” Per-tool toggles +- `staticAnalysis.maxFindings` β€” Cap total findings (default: 500) +- `staticAnalysis.minSeverity` β€” Filter threshold (`error`, `warning`, or `info`) +- `staticAnalysis.excludedPaths` β€” Glob patterns to skip + +--- + ## Next Steps - **[HTML_REPORT_GUIDE.md](./HTML_REPORT_GUIDE.md)** - Learn to read the report diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 38f8162..4e68173 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -86,6 +86,11 @@ Each agent focuses on specific evaluation pillars and brings unique perspectives - Risk analysis from business perspective - Recommendations for product improvements +**Static Analysis Categories Received**: + +- _Primary_: (none) +- _Secondary_: `security` + --- ### 2. Developer Author (πŸ‘¨β€πŸ’») @@ -146,6 +151,11 @@ Each agent focuses on specific evaluation pillars and brings unique perspectives - Team velocity and skill considerations - Risk factors for future similar work +**Static Analysis Categories Received**: + +- _Primary_: (none) +- _Secondary_: `quality`, `complexity`, `bug` + --- ### 3. Developer Reviewer (πŸ”) @@ -219,6 +229,11 @@ Each agent focuses on specific evaluation pillars and brings unique perspectives - Refactoring suggestions - Technical debt assessment +**Static Analysis Categories Received**: + +- _Primary_: `quality`, `style`, `bug` +- _Secondary_: `security`, `complexity`, `duplication` + --- ### 4. Senior Architect (πŸ›οΈ) @@ -294,6 +309,11 @@ Each agent focuses on specific evaluation pillars and brings unique perspectives - Scalability recommendations - Refactoring priorities +**Static Analysis Categories Received**: + +- _Primary_: `security`, `complexity`, `duplication` +- _Secondary_: (none) + --- ### 5. QA Engineer (πŸ§ͺ) @@ -368,6 +388,11 @@ Each agent focuses on specific evaluation pillars and brings unique perspectives - Quality gate validation - Regression prevention strategies +**Static Analysis Categories Received**: + +- _Primary_: (none) +- _Secondary_: `bug` + --- ## Agent Interaction Model diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 59aa1ff..54e5972 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -13,9 +13,10 @@ Complete technical architecture documentation for CodeWave system. 7. [Convergence Detection Algorithm](#convergence-detection-algorithm) 8. [LLM Integration](#llm-integration) 9. [RAG System](#rag-system) -10. [Output Generation](#output-generation) -11. [State Management](#state-management) -12. [Error Handling](#error-handling) +10. [Static Analysis Pipeline](#static-analysis-pipeline) +11. [Output Generation](#output-generation) +12. [State Management](#state-management) +13. [Error Handling](#error-handling) --- @@ -97,6 +98,12 @@ CodeWave is a multi-tier, event-driven system for AI-powered code review that co Γ’β€β€š Γ’β€β€š - Result Persistence Γ’β€β€š Γ’β€β€š Γ’β€β€š Γ’β€β€š - Batch Management Γ’β€β€š Γ’β€β€š Γ’β€β€š Γ’β€β€Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€Λœ Γ’β€β€š +Γ’β€β€š Ò”ŒÒ”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò”€Ò” Γ’β€β€š +Γ’β€β€š Γ’β€β€š Static Analysis Service Γ’β€β€š Γ’β€β€š +Γ’β€β€š Γ’β€β€š - Semgrep & Qlty Runners Γ’β€β€š Γ’β€β€š +Γ’β€β€š Γ’β€β€š - Changed-Line Scoping Γ’β€β€š Γ’β€β€š +Γ’β€β€š Γ’β€β€š - Finding Formatter & Agent Routing Γ’β€β€š Γ’β€β€š +Γ’β€β€š Γ’β€β€Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€Λœ Γ’β€β€š Γ’β€β€Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€β‚¬Γ’β€Λœ Γ’β€β€š Ò–¼ @@ -389,6 +396,15 @@ Fetch Commit from Git Γ’β€β€š Ò””Ò”€Ò–º If > 100KB: Initialize RAG Γ’β€β€š Ò–¼ +Run Static Analysis (if enabled) + Γ’β€β€š + Ò”œÒ”€Ò–º Resolve file scope & exclude patterns + Ò”œÒ”€Ò–º Run Semgrep + Qlty in parallel + Ò”œÒ”€Ò–º Filter by changed lines from diff + Ò”œÒ”€Ò–º Deduplicate, sort, cap findings + Ò””Ò”€Ò–º Build StaticAnalysisSummary + Γ’β€β€š + Ò–¼ Orchestrator.executeEvaluation() Γ’β€β€š Ò”œÒ”€Ò–º Round 1: All agents assess independently @@ -823,6 +839,176 @@ if (diffSize > ragThreshold) { --- +## Static Analysis Pipeline + +Optional feature that runs external static analysis tools (Semgrep, Qlty) on changed files and injects the findings into agent prompts as supplementary context. Tools never override agent judgment β€” findings are presented as advisory information that agents evaluate using their professional expertise. + +### Pipeline Overview + +``` +Config Init (codewave config --init) + β”‚ + β–Ό +ToolInstaller.ensureAvailable() + β”‚ β”œβ”€β–Ί Check ToolInstallerCache (per-tool) + β”‚ β”œβ”€β–Ί Probe system PATH + managed binaries + β”‚ └─► Return ToolAvailability { semgrep, qlty } + β”‚ + β–Ό +FileScopeResolver.resolve() + β”‚ β”œβ”€β–Ί Validate files exist on disk + β”‚ β”œβ”€β–Ί Apply excludedPaths globs + test patterns + β”‚ └─► Return scanFiles[] + skipped lists + β”‚ + β–Ό +RunnerRegistry.buildToolRunnerPlans() + β”‚ β”œβ”€β–Ί SemgrepRunner plan + β”‚ β”œβ”€β–Ί QltyCheckRunner plan + β”‚ └─► QltySmellsRunner plan + β”‚ + β–Ό +Execute Runners (Semgrep β•‘ Qlty pipeline) + β”‚ β”œβ”€β–Ί Semgrep runs in parallel with Qlty + β”‚ └─► Qlty check β†’ Qlty smells (sequential) + β”‚ + β–Ό +Post-Processing + β”‚ β”œβ”€β–Ί ChangedLinesScope: filter to diff-touched lines + β”‚ β”œβ”€β–Ί Deduplicate across runners + β”‚ β”œβ”€β–Ί Filter by minSeverity + β”‚ β”œβ”€β–Ί Sort (severity desc β†’ file β†’ line) + β”‚ └─► Cap at maxFindings + β”‚ + β–Ό +StaticAnalysisSummary β†’ LangGraph State + β”‚ + β–Ό +FindingFormatter.formatForAgent() + β”‚ β”œβ”€β–Ί buildRiskProfile() (rule-based, no LLM) + β”‚ β”œβ”€β–Ί filterFindingsForAgent() via CATEGORY_ROUTING + β”‚ └─► Capped at MAX_FINDINGS_SAFETY_CAP (200) + β”‚ + β–Ό +Agent Prompts (Round 1: full β”‚ Round 2+: condensed) +``` + +### Tool Installation + +Tools are installed once during `codewave config --init`, not at evaluation time. + +- **ToolInstaller**: Discovers or installs Semgrep and Qlty binaries +- **ToolInstallerCache**: Caches availability per-tool (allows retry if one tool fails) +- **ToolPaths**: Resolves managed binary paths under `~/.codewave/tools/` +- **tool-discovery**: Probes system PATH then falls back to managed install paths +- **tool-installation**: Handles `pip install semgrep` and Qlty's install script + +If tools are missing at evaluation time, the service logs a warning and continues without those analyzers. Run `codewave config --init` to install them. + +### Runners + +| Runner | Tool | Purpose | Output Format | +|--------|------|---------|---------------| +| `SemgrepRunner` | Semgrep | SAST β€” security, quality, bug detection | JSON (`--json`) | +| `QltyCheckRunner` | Qlty | Linting β€” quality, style, complexity | JSON (`--output-format=json`) | +| `QltySmellsRunner` | Qlty | Code smells β€” duplication, complexity | JSON (`smells --output-format=json`) | + +Semgrep and Qlty run in parallel. The two Qlty sub-commands (`check` and `smells`) run sequentially to avoid filesystem contention. + +### Unified Finding Type + +All runners normalize their output to `StaticAnalysisFinding`: + +```typescript +interface StaticAnalysisFinding { + source: 'semgrep' | 'qlty'; + runnerId: 'semgrep' | 'qlty-check' | 'qlty-smells'; + file: string; + line: number; + column: number; + message: string; + severity: 'error' | 'warning' | 'info'; + category: 'security' | 'quality' | 'complexity' + | 'duplication' | 'style' | 'bug'; + ruleId: string; + cwe?: string[]; // Semgrep-specific + owasp?: string[]; // Semgrep-specific + fixable?: boolean; // Qlty-specific +} +``` + +### Changed-Lines Scoping + +Only findings on lines actually changed by the commit are kept. This prevents reporting pre-existing issues that the author didn't touch. + +- **parseChangedLineRangesFromUnifiedDiff()**: Parses unified diff hunks into `Map` +- **findingMatchesChangedLines()**: Checks if a finding's file:line falls within a changed range +- Handles Git **C-quoted paths** (`"a/path with spaces"`) and **octal escape sequences** (`\303\251` β†’ `Γ©`) +- If the diff is unavailable or unparseable, filtering is skipped with a warning + +### Finding Formatter + +The `FindingFormatter` prepares findings for agent consumption: + +**Category Routing** β€” Each finding category has a primary owner and optional secondary agents: + +| Category | Primary Agent | Secondary Agents | +|----------|--------------|-----------------| +| `security` | Senior Architect | Business Analyst, Developer Reviewer | +| `quality` | Developer Reviewer | Developer Author | +| `complexity` | Senior Architect | Developer Author, Developer Reviewer | +| `duplication` | Senior Architect | Developer Reviewer | +| `style` | Developer Reviewer | _(none)_ | +| `bug` | Developer Reviewer | Developer Author, SDET | + +- **Primary** agents receive all severities (error, warning, info) +- **Secondary** agents receive only error + warning (info filtered out) + +**Risk Profile** (`buildRiskProfile`) β€” Pure counting logic, no LLM call: + +| Condition | Risk Level | +|-----------|-----------| +| errors >= 3 | HIGH | +| errors >= 1 | MEDIUM | +| warnings >= 5 | MODERATE | +| Otherwise | LOW | + +Also reports category breakdown and top-3 hotspot files. + +**Format Functions**: + +- `formatForAgent(summary, agentName)` β€” Round 1: full risk profile + all relevant findings (capped at `MAX_FINDINGS_SAFETY_CAP` = 200) +- `buildRound2Reference(summary)` β€” Round 2+: risk profile + error-severity findings only (condensed) + +### Agent Injection + +Static analysis flows from the service through LangGraph state to agent prompts: + +1. `StaticAnalysisService.analyze()` produces a `StaticAnalysisSummary` +2. Summary is stored in `CommitEvaluationState.staticAnalysisSummary` +3. Orchestrator passes it to `AgentContext.staticAnalysisSummary` +4. Each agent's prompt builder calls `formatForAgent()` to get expertise-filtered findings +5. Round 2+ agents receive `buildRound2Reference()` instead (condensed view) + +### Source Files + +| File | Purpose | +|------|---------| +| `src/services/static-analysis/static-analysis.service.ts` | Pipeline orchestrator | +| `src/services/static-analysis/types.ts` | `StaticAnalysisFinding`, `StaticAnalysisSummary`, config types | +| `src/services/static-analysis/finding-formatter.ts` | `CATEGORY_ROUTING`, `formatForAgent`, `buildRound2Reference` | +| `src/services/static-analysis/tool-installer.ts` | Tool discovery and installation | +| `src/services/static-analysis/tool-installer-cache.ts` | Per-tool availability caching | +| `src/services/static-analysis/tool-runner-registry.ts` | `buildToolRunnerPlans()` | +| `src/services/static-analysis/semgrep-runner.ts` | Semgrep execution and JSON parsing | +| `src/services/static-analysis/qlty-runner.ts` | Qlty check execution and parsing | +| `src/services/static-analysis/qlty-smells-runner.ts` | Qlty smells execution and parsing | +| `src/services/static-analysis/changed-lines-scope.ts` | Diff parsing, C-quoted paths, line filtering | +| `src/services/static-analysis/file-scope-resolver.ts` | File validation and glob exclusion | +| `src/agents/agent.interface.ts` | `AgentContext.staticAnalysisSummary` | +| `src/orchestrator/commit-evaluation-graph.ts` | LangGraph state wiring | + +--- + ## Output Generation ### HTML Report Structure diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 6bc0065..f7e0f26 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,28 @@ All notable changes to CodeWave are documented here. +## [0.0.6] - 2026-02-26 + +### Static Analysis Integration + +- **Runner Pipeline**: Added `SemgrepRunner`, `QltyCheckRunner`, and `QltySmellsRunner` with unified `StaticAnalysisFinding` output format +- **Tool Installation**: Setup-time tool install via `codewave config --init` with `ToolInstaller` and per-tool `ToolInstallerCache` +- **Changed-Line Scoping**: Filter findings to only lines touched by the commit diff, supporting C-quoted git paths and octal escape sequences +- **Category Routing**: Route findings to agents by expertise (`CATEGORY_ROUTING` table) with primary/secondary filtering +- **Finding Formatter**: `formatForAgent()` builds risk profiles and expertise-filtered finding blocks; `buildRound2Reference()` provides condensed view for subsequent rounds +- **Round-Aware Injection**: Full findings in Round 1 agent prompts, condensed reference in Round 2+ +- **Config Validation**: Fail-fast validation of all `staticAnalysis` fields at config load time +- **Runner Registry**: `buildToolRunnerPlans()` creates execution plans based on config and tool availability +- **File Scope Resolver**: Validates files exist, applies exclude globs and test-pattern filtering +- **Parallel Execution**: Semgrep runs in parallel with Qlty pipeline (check then smells) + +### Fixes + +- Fixed C-quoted git diff path parsing (`"a/path with spaces"`) and octal byte corruption +- Fixed tool installer cache to retry individual tools independently on transient failures +- Fixed Qlty recovery after `qlty init` failures +- Suppressed Semgrep Pro-engine-only warnings from evaluation output + ## [0.0.5] - 2025-11-27 ### OKR Generation Enhancements diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f6cf848..989f7be 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -382,6 +382,110 @@ codewave config set verbose true - Timing information - Error stack traces +### Static Analysis Validation (Fail-Fast) + +`staticAnalysis` configuration is validated at runtime when `.codewave.config.json` is loaded. +If values are invalid, CodeWave fails fast and reports field-level errors. + +Static analyzer installation is setup-time only via `codewave config --init`. +If evaluation logs `Static analysis requested, but tools are not installed. Continuing without unavailable analyzers.`, +run `codewave config --init` and choose `Yes` for static analyzer installation. + +`staticAnalysis.autoInstall` is retained for backward compatibility, but evaluation no longer auto-installs tools. + +#### Tool Installation + +Running `codewave config --init` with static analyzers enabled triggers the tool installation flow: + +1. **Semgrep** β€” installed via `pip install semgrep` (requires Python 3.8+). Provides SAST rules for security vulnerabilities, code quality issues, and bug patterns. Configured rule sets (e.g., `auto`, `p/security-audit`) are passed at scan time. + +2. **Qlty** β€” installed via Qlty's install script (downloads a managed binary). Provides linting (`qlty check`) and code smell detection (`qlty smells`). Requires a project-level `.qlty/qlty.toml` config (generated on first `qlty init`). + +**Managed binary caching**: Installed binaries are stored under `~/.codewave/tools/` and cached per-tool. If one tool fails to install, the other can still succeed and be cached independently. The cache is checked before every evaluation to avoid redundant discovery. + +**Re-installing tools**: If tools become unavailable (e.g., after a Python environment change), run `codewave config --init` again and select "Yes" for static analyzer installation. The installer will detect missing tools and re-install them. + +**System dependencies**: Qlty may require `xz` on some platforms. Set `staticAnalysis.autoInstallSystemDeps: true` to allow CodeWave to install system-level dependencies automatically (uses `brew` on macOS, `apt-get` on Linux). + +For deterministic `qlty check` results across machines, commit the project-level qlty config: + +- `.qlty/qlty.toml` must be tracked in git +- avoid relying on per-user generated `.qlty` state + +**Constraints:** + +- `staticAnalysis.enabled`: boolean +- `staticAnalysis.autoInstall`: boolean (deprecated compatibility field; ignored at evaluation runtime) +- `staticAnalysis.autoInstallSystemDeps`: boolean +- `staticAnalysis.excludedPaths`: array of non-empty strings +- `staticAnalysis.semgrep.enabled`: boolean +- `staticAnalysis.semgrep.rules`: array of non-empty strings +- `staticAnalysis.semgrep.rules` must contain at least one rule if `semgrep.enabled` is `true` +- `staticAnalysis.semgrep.timeout`: integer `>= 1` +- `staticAnalysis.qlty.enabled`: boolean +- `staticAnalysis.qlty.timeout`: integer `>= 1` +- `staticAnalysis.qlty.smells.enabled`: boolean +- `staticAnalysis.qlty.smells.timeout`: integer `>= 1` +- `staticAnalysis.qlty.smells.includeDuplication`: boolean +- `staticAnalysis.maxFindings`: integer in `[1, 5000]` +- `staticAnalysis.minSeverity`: one of `error`, `warning`, `info` + +**Valid example:** + +```json +{ + "staticAnalysis": { + "enabled": true, + "autoInstall": false, + "autoInstallSystemDeps": false, + "excludedPaths": ["node_modules/**", "dist/**"], + "semgrep": { + "enabled": true, + "rules": ["auto"], + "timeout": 60000 + }, + "qlty": { + "enabled": true, + "timeout": 60000, + "smells": { + "enabled": true, + "timeout": 60000, + "includeDuplication": true + } + }, + "maxFindings": 500, + "minSeverity": "warning" + } +} +``` + +**Invalid example (will fail):** + +```json +{ + "staticAnalysis": { + "enabled": "yes", + "autoInstallSystemDeps": "no", + "semgrep": { + "enabled": true, + "rules": [], + "timeout": 0 + }, + "qlty": { + "enabled": true, + "timeout": 60000, + "smells": { + "enabled": "yes", + "timeout": 0, + "includeDuplication": "sometimes" + } + }, + "maxFindings": 10000, + "minSeverity": "critical" + } +} +``` + --- ## LLM Provider Setup @@ -777,6 +881,133 @@ cp ~/.codewave/config.json.backup ~/.codewave/config.json --- +## MCP Configuration + +When using CodeWave as an MCP server with Claude Desktop or Cursor, the configuration works the same way as the CLI. + +### Configuration Methods + +**1. File-based configuration (recommended):** + +- Use `.codewave.config.json` in your repository root or home directory +- Run `codewave config --init` to create interactively +- MCP server will automatically detect and use this file +- Same configuration file used by both CLI and MCP + +**2. Inline configuration:** + +- Pass `provider`, `api_key`, and `model` directly in MCP tool calls +- Useful when no config file exists or for testing different providers +- All three parameters are required together +- Example (in Claude Desktop conversation): + +``` +"Evaluate HEAD with Google Gemini" +[Claude will prompt for api_key if needed] +``` + +Or you can provide all inline parameters: + +```json +{ + "commit_hash": "HEAD", + "provider": "google", + "api_key": "your-google-api-key", + "model": "gemini-2.0-flash-exp" +} +``` + +### Verifying Configuration + +Use the `check_config` MCP tool to verify your configuration: + +**In Claude Desktop:** +``` +"Check my CodeWave configuration" +``` + +This will return: +- **Provider**: anthropic, openai, google, etc. +- **Model**: claude-3-5-sonnet-20241022, gpt-4, etc. +- **API Key Status**: Present (βœ“) or Missing (βœ—) - never shows actual key +- **Enabled Agents**: BA, DA, DR, SA, QA +- **Configuration Path**: Where the config file is located + +### API Key Security + +**Important security notes:** + +1. **Never commit** `.codewave.config.json` to version control + ```bash + echo ".codewave.config.json" >> .gitignore + ``` + +2. **API keys are not logged**: Only the last 4 characters are shown in logs for debugging + +3. **Inline keys are not stored**: Keys passed inline in MCP calls are used once and discarded + +4. **Claude Desktop isolation**: Your API keys never leave your machine - MCP runs locally + +### Configuration Priority + +When both file and inline config are provided: + +1. **Inline parameters** (provider/api_key/model) take highest priority +2. **File configuration** used if no inline params provided +3. **Error** if neither is available + +**Example scenarios:** + +| Scenario | File Config | Inline Config | Result | +|----------|-------------|---------------|--------| +| Both present | βœ“ Anthropic | βœ“ Google | Uses **Google** (inline wins) | +| Only file | βœ“ Anthropic | βœ— None | Uses **Anthropic** | +| Only inline | βœ— None | βœ“ Google | Uses **Google** | +| Neither | βœ— None | βœ— None | **Error** - run config --init | + +### Claude Desktop Setup + +For complete Claude Desktop setup instructions, see: +- **[MCP_SETUP.md](./MCP_SETUP.md)** - Installation and configuration guide +- **[MCP_TOOLS_REFERENCE.md](./MCP_TOOLS_REFERENCE.md)** - Tool documentation + +### Example: Using Different Providers + +You can easily test different LLM providers without changing your config file: + +**With file config (Anthropic):** +``` +"Evaluate my last commit" +[Uses Claude from config file] +``` + +**Override with Google inline:** +``` +"Evaluate HEAD using Google Gemini with my API key" +[Claude will use inline Google config] +``` + +**Back to file config:** +``` +"Evaluate HEAD~1" +[Uses Claude from config file again] +``` + +### Environment Variables + +MCP server also supports environment variables for API keys: + +```bash +# Set in shell before starting Claude Desktop +export ANTHROPIC_API_KEY="your-anthropic-key" +export OPENAI_API_KEY="your-openai-key" +export GOOGLE_API_KEY="your-google-key" +``` + +Priority: **Inline > Config File > Environment Variables** + +--- + ## Troubleshooting ### "API Key not found" diff --git a/docs/INDEX.md b/docs/INDEX.md index a6abc92..10c166e 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -40,7 +40,7 @@ Complete guide to all CodeWave documentation. - Common disagreements - Round-by-round details -- **[ARCHITECTURE.md](./ARCHITECTURE.md)** (900+ lines) +- **[ARCHITECTURE.md](./ARCHITECTURE.md)** (1,230+ lines) - System overview and diagrams - Technology stack - Core components @@ -50,6 +50,7 @@ Complete guide to all CodeWave documentation. - Convergence detection algorithm - LLM integration - RAG system + - Static Analysis Pipeline - Output generation - State management - Error handling @@ -96,13 +97,40 @@ Complete guide to all CodeWave documentation. - Exporting and sharing - Troubleshooting -- **[ADVANCED_FEATURES.md](./ADVANCED_FEATURES.md)** (700+ lines) +- **[ADVANCED_FEATURES.md](./ADVANCED_FEATURES.md)** (1,100+ lines) - Developer Overview generation - Convergence Detection algorithm - Multi-Round Agent Discussion + - Agent RAG Query Tracking + - Static Analysis Integration - Understanding evaluation depth - Advanced usage patterns - - Analyzing discussion quality + +### MCP Integration (Claude Desktop / Cursor) + +- **[MCP_SETUP.md](./MCP_SETUP.md)** (~450 lines) + - What is MCP and Model Context Protocol + - Prerequisites and installation + - Claude Desktop configuration + - Step-by-step setup guide + - Verification and troubleshooting + - Connection issues and debugging + +- **[MCP_TOOLS_REFERENCE.md](./MCP_TOOLS_REFERENCE.md)** (~950 lines) + - Complete reference for all 10 MCP tools + - Tool parameters and validation rules + - Example requests and responses + - Error handling and error codes + - Response format specifications + - Performance characteristics + +- **[MCP_EXAMPLES.md](./MCP_EXAMPLES.md)** (~680 lines) + - Getting started workflow (5 minutes) + - Example Claude Desktop conversations + - Step-by-step workflow scenarios + - Advanced usage patterns + - Tips and tricks for better prompts + - CI/CD integration examples ### Developers @@ -134,15 +162,18 @@ Complete guide to all CodeWave documentation. | README.md | 750 | Overview & Getting Started | Everyone | | QUICK_REFERENCE.md | 540 | Fast Lookup | Users | | HTML_REPORT_GUIDE.md | 600 | Report Interpretation | Users | -| ADVANCED_FEATURES.md | 700 | Deep Dive Features | Power Users | +| ADVANCED_FEATURES.md | 1,100 | Deep Dive Features | Power Users | | AGENTS.md | 669 | Agent Deep-Dive | Implementers | -| ARCHITECTURE.md | 900 | System Design | Developers | +| ARCHITECTURE.md | 1,230 | System Design | Developers | | CLI.md | 708 | Command Reference | Users | | CONFIGURATION.md | 750 | Setup Guide | Users | | EXAMPLES.md | 688 | Practical Use Cases | Users | | API.md | 716 | Programmatic Access | Developers | | TROUBLESHOOTING.md | 824 | Support & Fixes | Everyone | -| **TOTAL** | **8,245** | | | +| MCP_SETUP.md | 450 | MCP Installation & Setup | Claude Users | +| MCP_TOOLS_REFERENCE.md| 950 | MCP Tool Reference | Claude Users | +| MCP_EXAMPLES.md | 680 | MCP Use Cases | Claude Users | +| **TOTAL** | **10,325**| | | ## Quick Navigation @@ -184,9 +215,18 @@ Complete guide to all CodeWave documentation. **Learn advanced features** β†’ [ADVANCED_FEATURES.md](./ADVANCED_FEATURES.md) - Deep dive into Developer Overview, Convergence, Multi-Round Discussion +**Set up static analysis** +β†’ [CONFIGURATION.md](./CONFIGURATION.md#static-analysis-validation-fail-fast) - Tool installation and config options + +**Understand how static analysis findings reach agents** +β†’ [ARCHITECTURE.md](./ARCHITECTURE.md#static-analysis-pipeline) - Full pipeline, category routing, finding formatter + **Track model usage and costs** β†’ [CONFIGURATION.md](./CONFIGURATION.md) - Model Display and Cost Tracking sections +**Use CodeWave in Claude Desktop** +β†’ [MCP_SETUP.md](./MCP_SETUP.md) - Configure MCP integration + ## Command Cheat Sheet ### Essential Commands @@ -254,6 +294,7 @@ START HERE | Examples | | | βœ“ | | βœ“ | βœ“ | | | | | Troubleshooting | | | | | | | | | βœ“ | | Scoring | βœ“ | βœ“ | | | | | | | | +| Static Analysis | | | | βœ“ | | | βœ“ | βœ“ | | | Evaluation | βœ“ | βœ“ | βœ“ | | βœ“ | βœ“ | βœ“ | βœ“ | βœ“ | ## Learning Paths @@ -287,6 +328,13 @@ START HERE 3. Enable verbose: `codewave evaluate HEAD --verbose` 4. Collect debug info and report issue +### Path 5: Claude Desktop Integration (30 min - 1 hour) + +1. Read [README.md](../README.md) - "MCP Integration" section (5 min) +2. Follow [MCP_SETUP.md](./MCP_SETUP.md) - Setup Claude Desktop (10 min) +3. Try examples from [MCP_EXAMPLES.md](./MCP_EXAMPLES.md) - Practice (15 min) +4. Bookmark [MCP_TOOLS_REFERENCE.md](./MCP_TOOLS_REFERENCE.md) - Reference + ## Search Tips ### By Error Message @@ -325,6 +373,13 @@ Search [CLI.md](./CLI.md) or [EXAMPLES.md](./EXAMPLES.md) - **LangGraph**: https://www.langchain.com/langgraph - **Commander.js**: https://github.com/tj/commander.js +### MCP Resources + +- **MCP Specification**: https://modelcontextprotocol.io +- **MCP Inspector**: https://github.com/modelcontextprotocol/inspector +- **Claude Desktop**: https://claude.ai/download +- **MCP SDK**: https://github.com/modelcontextprotocol/sdk + ## Feedback & Contributions - Report Issues: https://github.com/techdebtgpt/codewave/issues/new diff --git a/docs/MCP_EXAMPLES.md b/docs/MCP_EXAMPLES.md new file mode 100644 index 0000000..2043d2a --- /dev/null +++ b/docs/MCP_EXAMPLES.md @@ -0,0 +1,840 @@ +# CodeWave MCP Examples + +Practical examples and workflows for using CodeWave with Claude Desktop. + +## Table of Contents + +- [Getting Started (5 Minutes)](#getting-started-5-minutes) +- [Example Conversations](#example-conversations) + - [Scenario 1: Quick Commit Check](#scenario-1-quick-commit-check) + - [Scenario 2: Building Evaluation History](#scenario-2-building-evaluation-history) + - [Scenario 3: Author Analysis](#scenario-3-author-analysis) + - [Scenario 4: Generate OKRs](#scenario-4-generate-okrs) + - [Scenario 5: Compare Commits](#scenario-5-compare-commits) +- [Step-by-Step Workflows](#step-by-step-workflows) + - [Workflow 1: Single Commit Deep Dive](#workflow-1-single-commit-deep-dive) + - [Workflow 2: Building Evaluation History](#workflow-2-building-evaluation-history) + - [Workflow 3: Author Analysis](#workflow-3-author-analysis) + - [Workflow 4: Team Analysis](#workflow-4-team-analysis) + - [Workflow 5: CI/CD Integration](#workflow-5-cicd-integration) +- [Advanced Usage Patterns](#advanced-usage-patterns) +- [Tips and Tricks](#tips-and-tricks) + +--- + +## Getting Started (5 Minutes) + +This quick guide gets you evaluating commits in Claude Desktop. + +### Prerequisites + +1. **Setup complete**: Follow [MCP_SETUP.md](./MCP_SETUP.md) first +2. **In a git repository**: Navigate to a repo with commits +3. **Configuration ready**: Run `codewave config --init` if needed + +### Your First Evaluation + +Open Claude Desktop and start a new conversation: + +``` +You: "Check my CodeWave configuration" +``` + +**Claude will:** +- Use the `check_config` tool +- Show your provider, model, and API key status +- Confirm everything is ready + +Then ask: + +``` +You: "Evaluate my last commit" +``` + +**Claude will:** +- Use the `evaluate_commit` tool with `commit_hash: HEAD` +- Wait 30-60 seconds for evaluation +- Show you: + - Commit hash and message + - Overall quality score (0-10) + - Breakdown of metrics + - Agent summaries + - Concerns and recommendations + +**Example response from Claude:** + +> I've evaluated your commit `1fac813` ("feat: Add user authentication module"). +> +> **Overall Score: 8.5/10** +> +> Here's the breakdown: +> - Code Quality: 9.0/10 - Excellent structure and readability +> - Complexity: 7.5/10 - Some complex authentication logic +> - Technical Debt: 8.0/10 - Minimal debt introduced +> - Test Coverage: 8.0/10 - Good test coverage +> - Functional Impact: 9.0/10 - Critical security feature +> +> **Concerns identified:** +> 1. Missing rate limiting on login endpoint +> 2. Password validation could be stronger +> +> **Recommendations:** +> 1. Implement rate limiting (max 5 attempts/min) +> 2. Add password complexity requirements + +### What to Do Next + +- **Save your evaluation**: Ask "Evaluate my last 5 commits and save them" to build history +- **Analyze patterns**: Ask "Show me my code quality statistics" +- **Get detailed report**: Ask "Show me a detailed report for commit abc123" +- **Generate OKRs**: Ask "Generate OKRs for me based on my commits" + +--- + +## Example Conversations + +Real conversations showing how to use CodeWave tools naturally with Claude Desktop. + +### Scenario 1: Quick Commit Check + +**Goal**: Quickly evaluate recent work before pushing. + +``` +You: "I just finished implementing user authentication. Can you evaluate + my last commit and tell me if it's ready to push?" + +Claude: "I'll evaluate your latest commit for you." + +[Calls evaluate_commit with commit_hash: HEAD] + +Claude: "I've reviewed commit 1fac813 'feat: Add user authentication module'. + +Overall, this is solid work with a score of 8.5/10. Here's what stands out: + +**Strengths:** +- Excellent code structure and readability (9.0/10) +- Good test coverage (8.0/10) +- Critical functionality properly implemented + +**Areas to address before pushing:** +1. **Security**: Missing rate limiting on the login endpoint - this is a + critical security concern for production +2. **Password validation**: Current regex allows weak passwords + +**My recommendation:** +Add rate limiting and strengthen password validation before pushing. +These are security-critical features that should be in place from day one. + +Would you like me to suggest specific implementations for these fixes?" + +You: "Yes, please suggest how to implement rate limiting" + +Claude: [Provides implementation suggestions...] +``` + +**Key Points:** +- Natural conversation flow +- Claude interprets results and provides context +- Can ask follow-up questions +- Gets actionable advice, not just metrics + +--- + +### Scenario 2: Building Evaluation History + +**Goal**: Create evaluation history for a project. + +``` +You: "I want to build up evaluation history for our project. Can you + evaluate my last 10 commits and save them?" + +Claude: "I'll evaluate your last 10 commits and save the results for + future analysis." + +[Calls batch_evaluate with commits: "last:10", save: true] + +Claude: "I've completed evaluating your last 10 commits. Here's the summary: + +**Results:** +- Successfully evaluated: 10/10 commits +- Average quality score: 8.2/10 +- Date range: Jan 15 - Jan 28, 2024 +- Total evaluation time: 8 minutes + +**Quality Distribution:** +- Excellent (9.0+): 2 commits +- Good (8.0-8.9): 5 commits +- Fair (7.0-7.9): 3 commits +- Needs improvement (<7.0): 0 commits + +**Highest scoring commit:** +- abc123 (9.2/10): 'feat: Add comprehensive test suite' + +**Trends observed:** +Your code quality has been improving over this period. Recent commits +show better test coverage and lower complexity. + +All results are now saved to .evaluated-commits/ and you can use tools +like get_author_stats and generate_okr for deeper analysis." + +You: "Great! Can you show me my overall statistics?" + +Claude: [Calls get_author_stats...] +``` + +**Key Points:** +- Batch operations save time +- Results automatically saved for later use +- Claude provides interpretive summary +- Can immediately follow up with analytics + +--- + +### Scenario 3: Author Analysis + +**Goal**: Understand a developer's performance patterns. + +``` +You: "Can you analyze John's code quality statistics?" + +Claude: "I'll analyze John's commit history and show you his performance + statistics." + +[Calls get_author_stats with author: "john"] + +Claude: "Here's John's code quality analysis based on 25 evaluated commits + from Jan 1-28, 2024: + +**Overall Performance:** +- Average Quality Score: 8.2/10 +- Trend: **Improving** β†— +- Total Commits Analyzed: 25 + +**Strengths:** +1. Consistently high code quality (avg 8.5/10) +2. Strong functional impact (avg 8.7/10) +3. Good technical debt management + +**Areas for Improvement:** +1. Test coverage could be improved (avg 7.5/10) +2. Occasional complexity spikes in large features + +**Top Commits:** +1. abc123 (9.2): 'feat: Add user dashboard with tests' +2. def456 (9.0): 'refactor: Simplify authentication flow' +3. ghi789 (8.9): 'feat: Implement caching layer' + +**Trend Analysis:** +John's recent commits (last 12) average 8.6/10 compared to 7.9/10 for +earlier commits, showing clear improvement. The main driver is better +test coverage in recent work. + +**Recommendation:** +John is doing well overall and trending positively. Focus areas for growth +would be maintaining test coverage across all features and breaking down +complex features into smaller, more manageable commits." + +You: "This is great for his upcoming performance review. Can you generate + personalized OKRs for him?" + +Claude: [Calls generate_okr with author: "john"...] +``` + +**Key Points:** +- Comprehensive performance overview +- Identifies specific patterns +- Trend analysis shows improvement +- Natural follow-up to OKR generation + +--- + +### Scenario 4: Generate OKRs + +**Goal**: Create data-driven development goals. + +``` +You: "Generate personalized OKRs for Sarah based on her commit history" + +Claude: "I'll analyze Sarah's commits and generate personalized OKRs + and an action plan." + +[Calls generate_okr with author: "sarah", save: true] + +Claude: "I've generated personalized OKRs for Sarah based on 18 evaluated + commits. Here's the development plan: + +**Strong Points:** +- Exceptional test coverage (avg 9.2/10) +- Clean, maintainable code +- Good documentation practices + +**Areas for Growth:** +- Occasional high complexity in data processing +- Could improve architectural planning for large features + +**3-Month OKR: Master Complex System Design** + +Objective: Develop skills to handle complex features with low complexity scores + +Key Results: +1. Reduce average complexity score from 7.5 to 8.5+ +2. Complete 'Refactoring' and 'Clean Architecture' courses +3. Break down all features >300 lines into smaller modules +4. Get 2+ architectural design reviews from senior engineers + +**6-Month OKR: Become Technical Leader in Testing** + +Objective: Leverage testing expertise to mentor team + +Key Results: +1. Mentor 2-3 team members on testing practices +2. Lead testing strategy initiative for the team +3. Achieve consistent 9.5+ test coverage scores +4. Deliver testing best practices workshop + +**12-Month OKR: Senior Engineer Excellence** + +Objective: Demonstrate senior-level technical leadership + +Key Results: +1. Maintain 9.0+ overall quality across all commits +2. Lead 2+ major architectural initiatives +3. Mentor 5+ junior/mid-level developers +4. Contribute to engineering standards documentation + +**Action Plan (Prioritized):** + +HIGH PRIORITY (Next 2 weeks): +- Enroll in 'Refactoring' course (Martin Fowler) +- Schedule architectural design review session with tech lead +- Resources: Refactoring book, team design review schedule + +MEDIUM PRIORITY (Next month): +- Start breaking down large features (identify 3 candidates) +- Begin mentoring relationship with 2 team members +- Resources: Mentorship program guidelines + +LOW PRIORITY (Next quarter): +- Prepare testing workshop outline +- Document personal best practices +- Resources: Internal wiki, past workshop examples + +I've saved this OKR plan to .evaluated-commits/.okrs/sarah-2024-01-28.json +for future reference." + +You: "Excellent! Can you help me schedule these milestones?" + +Claude: [Provides scheduling suggestions...] +``` + +**Key Points:** +- Data-driven, personalized goals +- Builds on strengths, addresses weaknesses +- Actionable timeline with resources +- Saved for performance tracking + +--- + +### Scenario 5: Compare Commits + +**Goal**: Validate that refactoring improved code quality. + +``` +You: "I just refactored the authentication module. Can you compare commit + 1fac813 (before) with commit abc123 (after) to see if quality improved?" + +Claude: "I'll compare those two commits to show how the refactoring + affected code quality." + +[Calls compare_commits with commit_a: "1fac813", commit_b: "abc123"] + +Claude: "Here's the comparison of your refactoring work: + +**Baseline (1fac813): 'Initial authentication implementation'** +- Overall Score: 7.5/10 +- Date: Jan 20, 2024 + +**After Refactoring (abc123): 'Refactor authentication with tests'** +- Overall Score: 8.8/10 +- Date: Jan 28, 2024 + +**Changes:** + +βœ… **Significant Improvements:** +1. Overall Score: +1.3 points (7.5 β†’ 8.8) ⬆ +2. Test Coverage: +3.0 points (6.0 β†’ 9.0) ⬆⬆ +3. Complexity: +1.0 points (7.5 β†’ 8.5) ⬆ +4. Technical Debt: +0.8 points (7.5 β†’ 8.3) ⬆ + +βš–οΈ **No Change:** +- Functional Impact: 9.0/10 (maintained) + +**Summary:** +Your refactoring was highly successful! The biggest improvement was in +test coverage (+3.0 points), which is exactly what good refactoring +achieves. You also managed to reduce complexity while maintaining all +functionality. + +**Key Achievements:** +- Added comprehensive tests without changing behavior +- Simplified complex authentication logic +- Reduced technical debt +- Improved maintainability + +This is a great example of refactoring done right. The code is now +significantly more maintainable and testable." + +You: "Thanks! Should I merge this to main?" + +Claude: "Absolutely! This is a clear improvement across all metrics..." +``` + +**Key Points:** +- Validates refactoring efforts +- Shows specific improvements +- Identifies what worked well +- Provides confidence to merge + +--- + +## Step-by-Step Workflows + +Detailed workflows for common use cases. + +### Workflow 1: Single Commit Deep Dive + +**Goal**: Get comprehensive analysis of a specific commit. + +**Steps:** + +1. **Verify configuration**: + ``` + "Check my CodeWave configuration" + ``` + +2. **Evaluate with save enabled**: + ``` + "Evaluate commit 1fac813 with deep analysis and save the results" + ``` + + Claude will call: `evaluate_commit` with `depth: "deep"`, `save: true` + +3. **Get detailed report**: + ``` + "Show me the full detailed report for commit 1fac813" + ``` + + Claude will call: `get_report` with `format: "full"` + +4. **Export for sharing**: + ``` + "Export that evaluation to HTML at ./reports/auth-module-review.html" + ``` + + Claude will call: `export_report` with `format: "html"` + +**Use Case**: Code review preparation, documentation, important feature evaluation + +--- + +### Workflow 2: Building Evaluation History + +**Goal**: Create evaluation baseline for a project. + +**Steps:** + +1. **Evaluate recent commits**: + ``` + "Evaluate my last 20 commits and save them all" + ``` + + Claude will call: `batch_evaluate` with `commits: "last:20"`, `save: true` + +2. **Verify evaluations were saved**: + ``` + "List all my evaluations" + ``` + + Claude will call: `list_evaluations` + +3. **Filter by specific author**: + ``` + "Show me only evaluations by John from the last month" + ``` + + Claude will call: `list_evaluations` with `author: "john"`, `since: "[date]"` + +4. **Get individual reports**: + ``` + "Show me the detailed report for the highest scoring commit" + ``` + + Claude will call: `get_report` with the specific commit hash + +**Use Case**: Project onboarding, establishing quality baseline, historical analysis + +--- + +### Workflow 3: Author Analysis + +**Goal**: Comprehensive developer performance review. + +**Steps:** + +1. **Ensure author has evaluated commits**: + ``` + "List all evaluations by Sarah" + ``` + + If empty, evaluate their commits first: + ``` + "Evaluate the last 15 commits by Sarah and save them" + ``` + +2. **Get author statistics**: + ``` + "Show me Sarah's code quality statistics" + ``` + + Claude will call: `get_author_stats` with `author: "sarah"` + +3. **Review strengths and weaknesses**: + Ask Claude to elaborate on specific findings: + ``` + "What does the 'improving' trend mean for Sarah?" + ``` + ``` + "Can you explain Sarah's test coverage weakness in more detail?" + ``` + +4. **Generate personalized OKRs**: + ``` + "Generate development goals and OKRs for Sarah" + ``` + + Claude will call: `generate_okr` with `author: "sarah"` + +5. **Export OKRs for review meeting**: + ``` + "Can you summarize Sarah's OKRs in a format I can use for her review?" + ``` + +**Use Case**: Performance reviews, 1:1 meetings, career development planning + +--- + +### Workflow 4: Team Analysis + +**Goal**: Understand team-wide code quality patterns. + +**Steps:** + +1. **Ensure team has evaluated commits**: + ``` + "Evaluate the last 10 commits from each team member and save them" + ``` + +2. **Get team-wide statistics**: + ``` + "Show me team-wide code quality statistics" + ``` + + Claude will call: `get_team_stats` + +3. **Identify high performers**: + ``` + "Who are our top performers by code quality?" + ``` + +4. **Identify areas needing support**: + ``` + "Which team members need coaching or support?" + ``` + +5. **Compare team members**: + ``` + "Compare John's statistics with Sarah's" + ``` + + Claude will call `get_author_stats` for both and provide comparison + +6. **Generate team OKRs**: + ``` + "Based on team stats, what should our team-wide OKRs be?" + ``` + + Claude will synthesize insights and suggest goals + +**Use Case**: Sprint retrospectives, team planning, resource allocation + +--- + +### Workflow 5: CI/CD Integration + +**Goal**: Automate quality checks in deployment pipeline. + +**Pattern for Manual Testing:** + +``` +"Evaluate the commits in pull request #123 and tell me if they meet + our quality standards (8.0+ overall)" +``` + +Claude will: +1. Evaluate the commits (using fast mode for speed) +2. Check if they meet threshold +3. Provide recommendation + +**Example conversation:** + +``` +You: "Evaluate commits abc123, def456, and ghi789 from PR #123 using + fast mode and tell me if they're all above 8.0 quality" + +Claude: [Evaluates 3 commits in fast mode] + +Claude: "I've evaluated all 3 commits from PR #123: + +**Results:** +1. abc123: 8.3/10 βœ… Meets threshold +2. def456: 7.5/10 ❌ Below threshold +3. ghi789: 8.8/10 βœ… Meets threshold + +**Overall:** 2/3 commits meet the 8.0+ requirement. + +**Issue with def456:** +- Test coverage: 6.5/10 +- Complexity: 7.2/10 +- Missing tests for error handling + +**Recommendation:** +Request changes on def456. The commit needs additional tests before +merge. The complexity is acceptable, but untested error paths are +a risk. + +Would you like me to provide specific test cases that should be added?" + +You: "Yes, please suggest test cases" + +Claude: [Provides specific test recommendations...] +``` + +**Use Case**: Pre-merge quality gates, automated reviews, quality enforcement + +--- + +## Advanced Usage Patterns + +### Using Inline Configuration + +When working in an environment without `.codewave.config.json`: + +``` +You: "Evaluate HEAD using Google Gemini (I'll provide the API key)" + +Claude: [Prompts for parameters or you provide them] + +"Use provider: google, api_key: [your-key], model: gemini-2.0-flash-exp" +``` + +### Analysis Depth Selection + +**Fast mode** for quick checks: +``` +"Quickly evaluate my last commit using fast mode" +``` + +**Deep mode** for important reviews: +``` +"I need a thorough analysis of commit abc123 using deep mode" +``` + +### Filtering Evaluations by Date + +**Recent work only**: +``` +"Show me evaluations from the last week" +``` + +Claude will calculate date and use `since` parameter. + +**Specific date range**: +``` +"Show me all evaluations from January 2024" +``` + +### Partial Hash Matching + +You don't need full commit hashes: + +``` +"Show me the report for commit 1fac" +``` + +CodeWave will match the partial hash automatically. + +### Combining Tools in Conversation + +Natural multi-tool workflows: + +``` +You: "Evaluate my last 5 commits, find the best one, and export it to HTML" + +Claude: +1. [Calls batch_evaluate with commits: "last:5"] +2. Identifies highest scoring commit +3. [Calls export_report for that commit] +4. Provides summary with exported file path +``` + +--- + +## Tips and Tricks + +### Ask for Specific Insights + +Instead of: "Evaluate this commit" + +Try: +- "Evaluate this commit and focus on security concerns" +- "Evaluate this commit and tell me about test coverage" +- "Evaluate this commit and compare it to my average" + +Claude will emphasize relevant aspects. + +### Request Trending Analysis + +``` +"Show me my code quality trend over the last month" +``` + +``` +"Compare my first 10 commits with my last 10 commits" +``` + +### Find Specific Patterns + +``` +"Show me all my commits with test coverage below 7.0" +``` + +``` +"Find commits with high technical debt scores" +``` + +Claude will filter evaluation results accordingly. + +### Get Improvement Recommendations + +``` +"Based on my recent commits, what should I focus on improving?" +``` + +``` +"What's the fastest way to improve my overall quality score?" +``` + +### Track Progress Over Time + +``` +"Compare my average quality score this month vs last month" +``` + +``` +"Am I improving in test coverage? Show me the trend" +``` + +### Prepare for Code Reviews + +``` +"Evaluate all my commits from the current sprint and tell me which ones + might need discussion in code review" +``` + +### Competitive Team Analysis + +``` +"Who has the highest average quality score on the team?" +``` + +``` +"Show me how I rank compared to my teammates" +``` + +### Export for Stakeholders + +``` +"Export my top 5 commits to HTML reports for my manager" +``` + +### Save Time with Defaults + +Claude remembers context, so you can say: +``` +"Evaluate my last commit" +[Results shown] + +"Now evaluate the one before that" +[Claude understands "that" = HEAD~1] + +"Compare those two" +[Claude remembers both commits] +``` + +--- + +## Best Practices + +### 1. Build History First + +Always start by building evaluation history: +``` +"Evaluate my last 20 commits and save them" +``` + +This unlocks all analytics tools. + +### 2. Use Appropriate Depth + +- **Fast**: CI/CD, quick checks, large batches +- **Normal**: Regular evaluation, most use cases +- **Deep**: Important features, pre-merge reviews + +### 3. Save Strategically + +Save evaluations you might need later: +- Important commits +- Baseline evaluations +- Batch evaluations +- Pre-merge reviews + +### 4. Regular Check-ins + +Make it a habit: +``` +"Evaluate my commits from today and show me the summary" +``` + +### 5. Use Natural Language + +Don't worry about exact tool names or parameters. Ask naturally: +``` +βœ… "Show me my code quality stats" +❌ "Call get_author_stats with author: me" +``` + +Claude handles the tool mapping. + +--- + +## See Also + +- [MCP Setup Guide](./MCP_SETUP.md) - Installation and configuration +- [MCP Tools Reference](./MCP_TOOLS_REFERENCE.md) - Complete tool documentation +- [Configuration Guide](./CONFIGURATION.md) - Detailed configuration options +- [Quick Reference](./QUICK_REFERENCE.md) - Fast command lookup + +--- + +**Last Updated**: 2026-01-28 +**CodeWave Version**: 0.1.11+ diff --git a/docs/MCP_SETUP.md b/docs/MCP_SETUP.md new file mode 100644 index 0000000..a1be363 --- /dev/null +++ b/docs/MCP_SETUP.md @@ -0,0 +1,546 @@ +# CodeWave MCP Setup Guide + +Complete guide to setting up CodeWave as an MCP (Model Context Protocol) server for Claude Desktop. + +## Table of Contents + +- [What is MCP?](#what-is-mcp) +- [Prerequisites](#prerequisites) +- [Installation](#installation) + - [Step 1: Install CodeWave](#step-1-install-codewave) + - [Step 2: Configure CodeWave](#step-2-configure-codewave) + - [Step 3: Setup Claude Desktop](#step-3-setup-claude-desktop) + - [Step 4: Restart and Verify](#step-4-restart-and-verify) +- [Quick Verification](#quick-verification) +- [Troubleshooting](#troubleshooting) +- [Next Steps](#next-steps) + +--- + +## What is MCP? + +**MCP (Model Context Protocol)** is an open standard developed by Anthropic that allows AI applications like Claude Desktop to connect to external tools and data sources. + +### Why Use CodeWave with Claude Desktop? + +Instead of running command-line evaluations, you can now: + +- **Chat naturally** with Claude to evaluate commits +- **Ask questions** about code quality without memorizing commands +- **Get insights** through conversation instead of reading raw JSON +- **Analyze patterns** across commits with natural language queries +- **Generate reports** on demand without leaving your chat window + +### How It Works + +``` +You: "Evaluate my last commit and tell me if it's good" + ↓ +Claude Desktop β†’ CodeWave MCP Server β†’ Multi-Agent Evaluation β†’ Results + ↓ +Claude: "I've analyzed commit abc123. Overall score: 8.5/10..." +``` + +**Benefits over CLI:** +- No command syntax to remember +- Conversational interface +- Claude interprets results for you +- Can ask follow-up questions +- Combines tools in intelligent ways + +--- + +## Prerequisites + +Before setting up CodeWave MCP, ensure you have: + +### 1. Node.js β‰₯18.0.0 + +Check your version: +```bash +node --version +``` + +If you need to install or update Node.js: +- **macOS**: `brew install node` or download from [nodejs.org](https://nodejs.org) +- **Windows**: Download installer from [nodejs.org](https://nodejs.org) +- **Linux**: Use your package manager (apt, yum, pacman) + +### 2. Claude Desktop App + +Download and install from: +- **Official site**: [claude.ai/download](https://claude.ai/download) +- **macOS**: Available on the Mac App Store +- **Windows**: Direct download from Anthropic +- **Linux**: AppImage available + +### 3. Git Repository + +CodeWave evaluates git commits, so you need: +- A git repository to analyze +- Commits to evaluate +- Access to commit history + +### 4. LLM API Key + +CodeWave uses AI models for evaluation. You need an API key from one of: +- **Anthropic** (Claude): [console.anthropic.com](https://console.anthropic.com) +- **OpenAI** (GPT): [platform.openai.com](https://platform.openai.com) +- **Google** (Gemini): [ai.google.dev](https://ai.google.dev) +- **Groq** (Fast inference): [console.groq.com](https://console.groq.com) +- **Ollama** (Local, free): [ollama.ai](https://ollama.ai) - No API key needed +- **xAI** (Grok): [x.ai](https://x.ai) +- **LM Studio** (Local, free): [lmstudio.ai](https://lmstudio.ai) - No API key needed + +--- + +## Installation + +### Step 1: Install CodeWave + +#### Option A: Global Installation (Recommended) + +```bash +npm install -g @techdebtgpt/codewave +``` + +Verify installation: +```bash +codewave --version +``` + +#### Option B: Local Development + +If you're developing or testing: + +```bash +git clone https://github.com/techdebtgpt/codewave.git +cd codewave +npm install +npm run build +``` + +The MCP server binary will be at: `./dist/cli/mcp-server.js` + +--- + +### Step 2: Configure CodeWave + +CodeWave needs configuration before it can evaluate commits. Run the interactive setup: + +```bash +codewave config --init +``` + +This wizard will guide you through: + +1. **Choose LLM Provider**: anthropic, openai, google, groq, ollama, xai, lm-studio +2. **Enter API Key**: Your provider's API key (not needed for ollama/lm-studio) +3. **Select Model**: Recommended models for your provider +4. **Enable Agents**: Which analysis agents to use (default: all) +5. **Set Defaults**: Batch size, output directory, report preferences + +Configuration is saved to `.codewave.config.json` in your home directory or repository root. + +**Verify configuration:** +```bash +codewave config show +``` + +--- + +### Step 3: Setup Claude Desktop + +Now configure Claude Desktop to use CodeWave as an MCP server. + +#### Find Claude Desktop Config File + +The configuration file location depends on your operating system: + +**macOS:** +``` +~/Library/Application Support/Claude/claude_desktop_config.json +``` + +**Windows:** +``` +%APPDATA%/Claude/claude_desktop_config.json +``` + +Or typically: +``` +C:\Users\YourUsername\AppData\Roaming\Claude\claude_desktop_config.json +``` + +**Linux:** +``` +~/.config/Claude/claude_desktop_config.json +``` + +#### Create or Edit Configuration + +Open the `claude_desktop_config.json` file in a text editor. If it doesn't exist, create it. + +**For global npm installation:** + +First, find where CodeWave is installed: +```bash +npm root -g +``` + +This shows your global node_modules path (e.g., `/usr/local/lib/node_modules`) + +Then add to `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "codewave": { + "command": "node", + "args": [ + "/usr/local/lib/node_modules/@techdebtgpt/codewave/dist/cli/mcp-server.js" + ] + } + } +} +``` + +Replace `/usr/local/lib/node_modules` with your actual path from `npm root -g`. + +**For local development:** + +```json +{ + "mcpServers": { + "codewave": { + "command": "node", + "args": [ + "/absolute/path/to/codewave/dist/cli/mcp-server.js" + ] + } + } +} +``` + +Replace `/absolute/path/to/codewave` with the actual path to your cloned repository. + +**Important Notes:** +- Use **absolute paths** only (no `~` or relative paths) +- On Windows, use forward slashes `/` or escaped backslashes `\\` +- The JSON must be valid (trailing commas not allowed) + +**Example for Windows:** +```json +{ + "mcpServers": { + "codewave": { + "command": "node", + "args": [ + "C:/Users/John/AppData/Roaming/npm/node_modules/@techdebtgpt/codewave/dist/cli/mcp-server.js" + ] + } + } +} +``` + +#### If You Have Other MCP Servers + +If you already have other MCP servers configured, just add CodeWave: + +```json +{ + "mcpServers": { + "existing-server": { + "command": "...", + "args": ["..."] + }, + "codewave": { + "command": "node", + "args": [ + "/path/to/codewave/dist/cli/mcp-server.js" + ] + } + } +} +``` + +--- + +### Step 4: Restart and Verify + +1. **Save** the `claude_desktop_config.json` file +2. **Completely quit** Claude Desktop (not just close the window): + - **macOS**: Cmd+Q or right-click dock icon β†’ Quit + - **Windows**: Right-click system tray icon β†’ Exit + - **Linux**: Close all windows and quit from system tray +3. **Restart** Claude Desktop +4. **Check for CodeWave tools**: + - Look for the MCP icon or tools panel in Claude Desktop + - CodeWave tools should be visible in the available tools list + +--- + +## Quick Verification + +Test that CodeWave MCP is working correctly. + +### Test 1: Check Configuration + +Start a new conversation in Claude Desktop and ask: + +``` +Check my CodeWave configuration +``` + +**Expected response:** +Claude will use the `check_config` tool and show: +- Provider: anthropic/openai/google/etc. +- Model: claude-3-5-sonnet/gpt-4/gemini-2.0-flash/etc. +- API Key: Present (βœ“) or Missing (βœ—) +- Enabled Agents: BA, DA, DR, SA, QA + +### Test 2: List Evaluations + +If you have previous evaluations: + +``` +Show me my past CodeWave evaluations +``` + +**Expected response:** +Claude will use the `list_evaluations` tool and show a list of evaluated commits with metrics. + +### Test 3: Evaluate a Commit + +Navigate to a git repository and ask: + +``` +Evaluate my last commit +``` + +**Expected response:** +Claude will use the `evaluate_commit` tool, which typically takes 30-60 seconds, and then show: +- Commit hash and message +- Overall quality score (0-10) +- Metrics breakdown (code quality, complexity, technical debt, etc.) +- Agent summaries +- Concerns and recommendations + +--- + +## Troubleshooting + +### Tools Not Appearing in Claude Desktop + +**Symptoms:** +- CodeWave tools don't show in the tools list +- Claude says "I don't have access to CodeWave tools" + +**Solutions:** + +1. **Verify config file location**: + ```bash + # macOS/Linux + ls -la ~/Library/Application\ Support/Claude/claude_desktop_config.json + + # Windows + dir %APPDATA%\Claude\claude_desktop_config.json + ``` + +2. **Check JSON syntax**: + - Use a JSON validator: [jsonlint.com](https://jsonlint.com) + - No trailing commas + - All quotes must be double quotes `"` + - Paths must use forward slashes `/` or escaped backslashes `\\` + +3. **Verify absolute path**: + ```bash + # Test the path directly + node /absolute/path/to/codewave/dist/cli/mcp-server.js + ``` + + If this fails, the path is incorrect. + +4. **Check permissions**: + ```bash + # macOS/Linux - make sure file is readable + chmod +r /path/to/codewave/dist/cli/mcp-server.js + ``` + +5. **Restart Claude Desktop completely**: + - Don't just close windows - fully quit the application + - Wait 5 seconds + - Reopen Claude Desktop + +### "Server Not Responding" Errors + +**Symptoms:** +- Tools appear but fail when used +- "MCP server error" messages +- Timeouts + +**Solutions:** + +1. **Check CodeWave configuration**: + ```bash + codewave config show + ``` + + Verify API key is set and valid. + +2. **Test MCP server directly**: + ```bash + node /path/to/codewave/dist/cli/mcp-server.js + ``` + + It should start without errors and wait for input. + +3. **Check Node.js version**: + ```bash + node --version + ``` + + Must be β‰₯18.0.0 + +4. **Rebuild if using local development**: + ```bash + cd /path/to/codewave + npm run build + ``` + +### Path Issues (Relative vs Absolute) + +**Symptoms:** +- "Cannot find module" errors +- Path not found + +**Solutions:** + +1. **Always use absolute paths** in `claude_desktop_config.json` +2. **Don't use `~` for home directory** - expand to full path +3. **Find npm global modules**: + ```bash + npm root -g + ``` + + Then add `/node_modules/@techdebtgpt/codewave/dist/cli/mcp-server.js` + +4. **Windows paths**: + - Use forward slashes: `C:/Users/...` + - Or escaped backslashes: `C:\\Users\\...` + - Don't mix both + +### Permission Issues + +**Symptoms:** +- "Permission denied" errors +- "EACCES" errors + +**Solutions:** + +1. **macOS/Linux - Check file permissions**: + ```bash + ls -la /path/to/codewave/dist/cli/mcp-server.js + ``` + + Should be readable (r-- at minimum) + +2. **Fix permissions**: + ```bash + chmod +r /path/to/codewave/dist/cli/mcp-server.js + ``` + +3. **Check directory permissions**: + ```bash + ls -la /path/to/codewave/dist/cli/ + ``` + +### Debugging with MCP Inspector + +For advanced debugging, use the MCP Inspector tool: + +```bash +# Install MCP Inspector +npm install -g @modelcontextprotocol/inspector + +# Run CodeWave with inspector +npx @modelcontextprotocol/inspector node /path/to/codewave/dist/cli/mcp-server.js +``` + +This opens a web UI where you can: +- Test tool calls manually +- See request/response JSON +- Debug parameter validation +- Check error messages + +### Checking Logs + +Claude Desktop logs MCP server errors. Check: + +**macOS:** +```bash +tail -f ~/Library/Logs/Claude/mcp*.log +``` + +**Windows:** +``` +%APPDATA%\Claude\logs\ +``` + +**Linux:** +```bash +tail -f ~/.config/Claude/logs/mcp*.log +``` + +--- + +## Next Steps + +Now that CodeWave MCP is set up, you can: + +1. **Learn about available tools**: See [MCP_TOOLS_REFERENCE.md](./MCP_TOOLS_REFERENCE.md) + - 10 tools for evaluation, analytics, and reporting + - Complete parameter reference + - Example requests and responses + +2. **Try practical examples**: See [MCP_EXAMPLES.md](./MCP_EXAMPLES.md) + - Example conversations with Claude Desktop + - Step-by-step workflows + - Tips and tricks + +3. **Understand MCP configuration**: See [CONFIGURATION.md](./CONFIGURATION.md) + - File-based vs inline configuration + - API key security + - Configuration priority + +4. **Quick reference**: See [QUICK_REFERENCE.md](./QUICK_REFERENCE.md) + - Most common tool calls + - Setup checklist + - MCP vs CLI comparison + +--- + +## Additional Resources + +### Official Documentation + +- **MCP Specification**: [modelcontextprotocol.io](https://modelcontextprotocol.io) +- **MCP GitHub**: [github.com/modelcontextprotocol](https://github.com/modelcontextprotocol) +- **Claude Desktop**: [claude.ai/download](https://claude.ai/download) + +### CodeWave Documentation + +- **Main README**: [README.md](../README.md) +- **CLI Reference**: [CLI.md](./CLI.md) +- **Architecture**: [ARCHITECTURE.md](./ARCHITECTURE.md) +- **Troubleshooting**: [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) + +### Getting Help + +- **Issues**: [github.com/techdebtgpt/codewave/issues](https://github.com/techdebtgpt/codewave/issues) +- **Discussions**: [github.com/techdebtgpt/codewave/discussions](https://github.com/techdebtgpt/codewave/discussions) + +--- + +**Last Updated**: 2026-01-28 +**CodeWave Version**: 0.1.11+ diff --git a/docs/MCP_TOOLS_REFERENCE.md b/docs/MCP_TOOLS_REFERENCE.md new file mode 100644 index 0000000..7e98bf8 --- /dev/null +++ b/docs/MCP_TOOLS_REFERENCE.md @@ -0,0 +1,1360 @@ +# CodeWave MCP Tools Reference + +Complete reference for all CodeWave MCP tools available in Claude Desktop. + +## Table of Contents + +- [Overview](#overview) +- [Tool Index](#tool-index) +- [Phase 0: Core Evaluation](#phase-0-core-evaluation) + - [evaluate_commit](#evaluate_commit) +- [Phase 1: Configuration & Data Retrieval](#phase-1-configuration--data-retrieval) + - [check_config](#check_config) + - [list_evaluations](#list_evaluations) + - [get_report](#get_report) +- [Phase 2: Analytics & OKR Generation](#phase-2-analytics--okr-generation) + - [get_author_stats](#get_author_stats) + - [generate_okr](#generate_okr) +- [Phase 3: Batch Operations & Export](#phase-3-batch-operations--export) + - [batch_evaluate](#batch_evaluate) + - [compare_commits](#compare_commits) + - [get_team_stats](#get_team_stats) + - [export_report](#export_report) +- [Error Handling](#error-handling) +- [Response Formats](#response-formats) + +--- + +## Overview + +CodeWave provides **10 production-ready MCP tools** organized into 4 phases: + +| Tool | Phase | Purpose | Makes API Calls? | Requires Saved Evaluations? | +|------|-------|---------|------------------|----------------------------| +| `evaluate_commit` | 0 | Core evaluation using multi-agent AI | βœ… Yes | ❌ No | +| `check_config` | 1 | Verify CodeWave configuration | ❌ No | ❌ No | +| `list_evaluations` | 1 | Browse evaluation history | ❌ No | βœ… Yes | +| `get_report` | 1 | Get detailed commit report | ❌ No | βœ… Yes | +| `get_author_stats` | 2 | Analyze developer statistics | ❌ No | βœ… Yes | +| `generate_okr` | 2 | AI-powered OKR generation | βœ… Yes | βœ… Yes | +| `batch_evaluate` | 3 | Evaluate multiple commits | βœ… Yes | ❌ No | +| `compare_commits` | 3 | Compare two commit metrics | ❌ No | βœ… Yes | +| `get_team_stats` | 3 | Aggregate team statistics | ❌ No | βœ… Yes | +| `export_report` | 3 | Export report to file | ❌ No | βœ… Yes | + +**Key Concepts:** + +- **Makes API Calls**: Tools that call LLM APIs cost money and take longer (30-60 seconds) +- **Requires Saved Evaluations**: Tools that need evaluations saved with `save: true` +- **Phase 0**: Foundation - core evaluation functionality +- **Phase 1**: Read-only operations on config and saved data +- **Phase 2**: Analytics and AI-powered insights +- **Phase 3**: Batch operations and export functionality + +--- + +## Tool Index + +Quick navigation to tool documentation: + +**Core:** +- [evaluate_commit](#evaluate_commit) - Evaluate a single commit with multi-agent AI + +**Configuration:** +- [check_config](#check_config) - Check if configuration exists and is valid + +**Data Retrieval:** +- [list_evaluations](#list_evaluations) - List past evaluations with filtering +- [get_report](#get_report) - Get detailed report for a commit + +**Analytics:** +- [get_author_stats](#get_author_stats) - Get developer statistics +- [generate_okr](#generate_okr) - Generate OKRs with AI + +**Batch Operations:** +- [batch_evaluate](#batch_evaluate) - Evaluate multiple commits +- [compare_commits](#compare_commits) - Compare metrics between commits +- [get_team_stats](#get_team_stats) - Get team-wide statistics +- [export_report](#export_report) - Export report to HTML/MD/JSON + +--- + +## Phase 0: Core Evaluation + +### evaluate_commit + +Evaluate a git commit using CodeWave's multi-agent AI analysis system. + +**Purpose:** +- Get comprehensive code quality assessment +- Identify technical debt and complexity issues +- Understand functional impact and test coverage +- Receive actionable recommendations from 5 specialized AI agents + +**When to use:** +- You want to evaluate your latest work +- You need detailed analysis of a specific commit +- You're preparing for code review +- You want to track code quality over time + +**⏱️ Typical Execution Time:** 30-60 seconds (varies by commit size and depth mode) + +#### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `commit_hash` | string | βœ… Yes | - | Git commit hash (SHA, short SHA, or HEAD) | +| `repo_path` | string | ❌ No | current directory | Path to git repository | +| `depth` | string | ❌ No | `normal` | Analysis depth: `fast`, `normal`, or `deep` | +| `save` | boolean | ❌ No | `false` | Save results to `.evaluated-commits/` folder | +| `provider` | string | ❌ No | from config | LLM provider (anthropic, openai, google, groq, ollama, xai, lm-studio) | +| `api_key` | string | ❌ No | from config | API key for LLM provider | +| `model` | string | ❌ No | from config | Model name (e.g., claude-3-5-sonnet-20241022) | + +**Important Notes:** +- Inline config requires **all three** parameters: `provider`, `api_key`, `model` +- Use `save: true` if you want to use analytics tools later (`get_author_stats`, `generate_okr`) +- `HEAD` evaluates the most recent commit +- Short commit hashes (7+ characters) are supported + +#### Depth Modes + +| Mode | Speed | Detail Level | Use Case | +|------|-------|--------------|----------| +| `fast` | ~20-30s | Quick overview | Quick checks, CI/CD | +| `normal` | ~30-45s | Standard analysis | Regular evaluation | +| `deep` | ~45-60s | Thorough review | Pre-merge, important changes | + +#### Example Requests + +**1. Basic evaluation (using config file):** +```json +{ + "commit_hash": "HEAD" +} +``` + +**2. Evaluation with save and depth:** +```json +{ + "commit_hash": "HEAD", + "depth": "deep", + "save": true +} +``` + +**3. Specific commit evaluation:** +```json +{ + "commit_hash": "1fac813", + "save": true +} +``` + +**4. Inline configuration (no config file):** +```json +{ + "commit_hash": "HEAD", + "provider": "google", + "api_key": "your-google-api-key", + "model": "gemini-2.0-flash-exp", + "save": true +} +``` + +**5. Different repository:** +```json +{ + "commit_hash": "HEAD", + "repo_path": "/path/to/other/repo" +} +``` + +#### Response Format + +The response includes: + +```typescript +{ + commitHash: string; // Full commit SHA + commitMessage: string; // Commit message + author: string; // Commit author + timestamp: string; // ISO 8601 timestamp + + metrics: { + overallScore: number; // 0-10 overall quality score + codeQuality: number; // 0-10 code quality score + complexity: number; // 0-10 complexity score + technicalDebt: number; // 0-10 technical debt score + testCoverage: number; // 0-10 test coverage score + functionalImpact: number; // 0-10 functional impact score + }; + + agentSummaries: { + [agentName: string]: { + initialAssessment: string; + concerns: string[]; + finalVerdict: string; + }; + }; + + concerns: string[]; // Critical issues identified + recommendations: string[]; // Actionable recommendations + + // If save: true was used + savedTo?: string; // Path where results were saved +} +``` + +#### Example Response + +```json +{ + "commitHash": "1fac813abc...", + "commitMessage": "feat: Add user authentication module", + "author": "John Doe", + "timestamp": "2024-01-28T10:30:00Z", + "metrics": { + "overallScore": 8.5, + "codeQuality": 9.0, + "complexity": 7.5, + "technicalDebt": 8.0, + "testCoverage": 8.0, + "functionalImpact": 9.0 + }, + "agentSummaries": { + "Business Analyst": { + "initialAssessment": "Authentication is a critical security feature...", + "concerns": ["Missing rate limiting", "No password complexity rules"], + "finalVerdict": "Solid foundation, needs security enhancements" + } + }, + "concerns": [ + "Missing rate limiting on login endpoint", + "Password validation could be stronger" + ], + "recommendations": [ + "Implement rate limiting (max 5 attempts/min)", + "Add password complexity requirements" + ], + "savedTo": ".evaluated-commits/1fac813abc-2024-01-28/" +} +``` + +--- + +## Phase 1: Configuration & Data Retrieval + +### check_config + +Check if CodeWave configuration exists and is valid. + +**Purpose:** +- Verify configuration before running evaluations +- Diagnose configuration issues +- Check API key status +- See which agents are enabled + +**When to use:** +- Before your first evaluation +- Troubleshooting setup issues +- Verifying API key is set correctly +- Checking which provider/model is configured + +#### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `repo_path` | string | ❌ No | current directory | Path to repository | + +#### Example Requests + +**1. Check current directory config:** +```json +{} +``` + +**2. Check specific repository:** +```json +{ + "repo_path": "/path/to/repo" +} +``` + +#### Response Format + +```typescript +{ + configExists: boolean; + configPath?: string; + provider?: string; // e.g., "anthropic" + model?: string; // e.g., "claude-3-5-sonnet-20241022" + apiKeyPresent: boolean; // true if API key is set (never shows actual key) + enabledAgents?: string[]; // e.g., ["BA", "DA", "DR", "SA", "QA"] + error?: string; // If config invalid or missing +} +``` + +#### Example Response + +```json +{ + "configExists": true, + "configPath": "/Users/john/projects/myapp/.codewave.config.json", + "provider": "anthropic", + "model": "claude-3-5-sonnet-20241022", + "apiKeyPresent": true, + "enabledAgents": ["BA", "DA", "DR", "SA", "QA"] +} +``` + +--- + +### list_evaluations + +List all past commit evaluations with optional filtering. + +**Purpose:** +- Browse evaluation history +- Find commits evaluated by specific authors +- Filter evaluations by date range +- Get quick overview of evaluated commits + +**When to use:** +- You want to see what's been evaluated +- You're looking for a specific commit +- You want to filter by author or date +- You need commit hashes for other tools + +**⚠️ Requirement:** Evaluations must have been saved with `save: true` + +#### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `repo_path` | string | ❌ No | current directory | Path to repository | +| `author` | string | ❌ No | - | Filter by author (case-insensitive partial match) | +| `since` | string | ❌ No | - | Only evaluations after this date (ISO format: YYYY-MM-DD) | +| `limit` | number | ❌ No | `20` | Maximum results (max: 100) | + +#### Example Requests + +**1. List recent evaluations:** +```json +{ + "limit": 10 +} +``` + +**2. Filter by author:** +```json +{ + "author": "john", + "limit": 20 +} +``` + +**3. Filter by date:** +```json +{ + "since": "2024-01-01", + "limit": 50 +} +``` + +**4. Combined filters:** +```json +{ + "author": "sarah", + "since": "2024-01-15", + "limit": 30 +} +``` + +#### Response Format + +```typescript +{ + totalFound: number; + evaluations: Array<{ + commitHash: string; + shortHash: string; // First 7 characters + author: string; + date: string; // ISO 8601 + message: string; + overallScore: number; // 0-10 + codeQuality: number; + complexity: number; + technicalDebt: number; + testCoverage: number; + functionalImpact: number; + }>; +} +``` + +#### Example Response + +```json +{ + "totalFound": 15, + "evaluations": [ + { + "commitHash": "1fac813abc123...", + "shortHash": "1fac813", + "author": "John Doe", + "date": "2024-01-28T10:30:00Z", + "message": "feat: Add user authentication", + "overallScore": 8.5, + "codeQuality": 9.0, + "complexity": 7.5, + "technicalDebt": 8.0, + "testCoverage": 8.0, + "functionalImpact": 9.0 + } + ] +} +``` + +--- + +### get_report + +Get detailed evaluation report for a specific commit. + +**Purpose:** +- Retrieve full evaluation data +- Review agent summaries and discussions +- Access concerns and recommendations +- Export data for analysis + +**When to use:** +- You need detailed information about a commit +- You want to see agent reasoning +- You need to review concerns +- You want full metrics and summaries + +**⚠️ Requirement:** Commit must have been evaluated with `save: true` + +#### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `commit_hash` | string | βœ… Yes | - | Commit hash (supports partial match: 4-40 characters) | +| `repo_path` | string | ❌ No | current directory | Path to repository | +| `format` | string | ❌ No | `summary` | Detail level: `metrics_only`, `summary`, or `full` | + +#### Format Modes + +| Mode | Includes | Use Case | +|------|----------|----------| +| `metrics_only` | Just numeric scores | Quick comparison, data analysis | +| `summary` | Metrics + agent summaries + concerns | Standard use case | +| `full` | Everything (including raw conversation data) | Deep analysis, debugging | + +#### Example Requests + +**1. Get summary report:** +```json +{ + "commit_hash": "1fac813" +} +``` + +**2. Get full report:** +```json +{ + "commit_hash": "1fac813", + "format": "full" +} +``` + +**3. Get metrics only:** +```json +{ + "commit_hash": "HEAD~1", + "format": "metrics_only" +} +``` + +#### Response Format + +See [evaluate_commit Response Format](#response-format) for full structure. + +--- + +## Phase 2: Analytics & OKR Generation + +### get_author_stats + +Get aggregated code quality statistics for a specific developer. + +**Purpose:** +- Analyze developer performance over time +- Identify strengths and weaknesses +- Track improvement trends +- Generate data-driven insights + +**When to use:** +- Performance reviews +- Developer coaching +- Team retrospectives +- Identifying training needs + +**⚠️ Requirement:** Author must have evaluated commits with `save: true` + +**⏱️ Execution Time:** < 1 second (no API calls) + +#### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `author` | string | βœ… Yes | - | Author name (case-insensitive partial match) | +| `repo_path` | string | ❌ No | current directory | Path to repository | +| `since` | string | ❌ No | - | Only include evaluations after this date (YYYY-MM-DD) | +| `limit` | number | ❌ No | `50` | Maximum commits to analyze | + +#### Example Requests + +**1. Get author stats:** +```json +{ + "author": "john" +} +``` + +**2. With date filter:** +```json +{ + "author": "sarah", + "since": "2024-01-01" +} +``` + +**3. Limited analysis:** +```json +{ + "author": "john", + "limit": 20 +} +``` + +#### Response Format + +```typescript +{ + author: string; + commitsAnalyzed: number; + dateRange: { + start: string; + end: string; + }; + + averageMetrics: { + overallScore: number; + codeQuality: number; + complexity: number; + technicalDebt: number; + testCoverage: number; + functionalImpact: number; + }; + + trend: "improving" | "declining" | "stable"; + + strengths: string[]; // Top 3 strengths + weaknesses: string[]; // Top 3 weaknesses + + topCommits: Array<{ // Best 5 commits + hash: string; + score: number; + message: string; + }>; +} +``` + +#### Trend Calculation + +- **Improving**: Recent commits (last 50%) score higher than older commits +- **Declining**: Recent commits score lower than older commits +- **Stable**: No significant change (< 0.5 point difference) +- Requires at least 4 commits for trend analysis + +#### Example Response + +```json +{ + "author": "John Doe", + "commitsAnalyzed": 25, + "dateRange": { + "start": "2024-01-01", + "end": "2024-01-28" + }, + "averageMetrics": { + "overallScore": 8.2, + "codeQuality": 8.5, + "complexity": 7.8, + "technicalDebt": 8.0, + "testCoverage": 7.5, + "functionalImpact": 8.7 + }, + "trend": "improving", + "strengths": [ + "Consistently high code quality scores (avg 8.5/10)", + "Strong functional impact (avg 8.7/10)", + "Good technical debt management" + ], + "weaknesses": [ + "Test coverage could be improved (avg 7.5/10)", + "Occasional complexity spikes in large features" + ], + "topCommits": [ + { + "hash": "abc123", + "score": 9.2, + "message": "feat: Add user dashboard with tests" + } + ] +} +``` + +--- + +### generate_okr + +Generate personalized OKRs (Objectives and Key Results) and improvement action plan for a developer using AI. + +**Purpose:** +- Create data-driven growth objectives +- Identify specific areas for improvement +- Generate actionable 3/6/12-month goals +- Provide personalized development roadmap + +**When to use:** +- Quarterly planning +- Performance reviews +- Career development planning +- Team goal setting + +**⚠️ Requirements:** +- Author must have evaluated commits with `save: true` +- Valid configuration with API key (or use inline config) + +**⏱️ Typical Execution Time:** 10-30 seconds (makes API calls) + +#### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `author` | string | βœ… Yes | - | Author name to generate OKRs for | +| `repo_path` | string | ❌ No | current directory | Path to repository | +| `since` | string | ❌ No | - | Only include evaluations after this date | +| `save` | boolean | ❌ No | `true` | Save OKRs to `.evaluated-commits/.okrs/` folder | +| `provider` | string | ❌ No | from config | LLM provider (for inline config) | +| `api_key` | string | ❌ No | from config | API key (for inline config) | +| `model` | string | ❌ No | from config | Model name (for inline config) | + +#### Example Requests + +**1. Generate OKRs (using config):** +```json +{ + "author": "john" +} +``` + +**2. With date filter and no save:** +```json +{ + "author": "sarah", + "since": "2024-01-01", + "save": false +} +``` + +**3. Inline configuration:** +```json +{ + "author": "john", + "provider": "anthropic", + "api_key": "your-api-key", + "model": "claude-3-5-sonnet-20241022" +} +``` + +#### Response Format + +```typescript +{ + author: string; + generatedAt: string; // ISO 8601 timestamp + basedOnCommits: number; + + strongPoints: string[]; // Top strengths + weakPoints: string[]; // Areas for improvement + knowledgeGaps: string[]; // Learning opportunities + + okr3Month: { + objective: string; + keyResults: string[]; // 3-4 measurable key results + }; + + okr6Month: { + objective: string; + keyResults: string[]; + }; + + okr12Month: { + objective: string; + keyResults: string[]; + }; + + actionPlan: Array<{ + priority: "high" | "medium" | "low"; + action: string; + timeline: string; + resources: string[]; + }>; + + savedTo?: string; // If save: true +} +``` + +#### Example Response + +```json +{ + "author": "John Doe", + "generatedAt": "2024-01-28T15:00:00Z", + "basedOnCommits": 25, + "strongPoints": [ + "Excellent code quality and maintainability", + "Strong architectural thinking", + "Good documentation practices" + ], + "weakPoints": [ + "Test coverage needs improvement", + "Occasionally high complexity in large features" + ], + "knowledgeGaps": [ + "Test-driven development practices", + "Refactoring techniques for complex code" + ], + "okr3Month": { + "objective": "Establish comprehensive testing practices", + "keyResults": [ + "Achieve 80%+ test coverage on all new code", + "Complete TDD fundamentals course", + "Write tests before implementation for all new features" + ] + }, + "okr6Month": { + "objective": "Master code complexity management", + "keyResults": [ + "Reduce average complexity score from 7.8 to 8.5+", + "Complete refactoring patterns training", + "Break down all features into smaller components (<200 lines)" + ] + }, + "okr12Month": { + "objective": "Become technical leader in testing and clean code", + "keyResults": [ + "Mentor 2-3 team members on testing practices", + "Achieve consistent 9.0+ overall quality scores", + "Lead testing strategy initiative for team" + ] + }, + "actionPlan": [ + { + "priority": "high", + "action": "Complete comprehensive TDD course", + "timeline": "Next 2 weeks", + "resources": ["Test-Driven Development by Example (Kent Beck)", "Udemy TDD course"] + } + ], + "savedTo": ".evaluated-commits/.okrs/john-doe-2024-01-28.json" +} +``` + +--- + +## Phase 3: Batch Operations & Export + +### batch_evaluate + +Evaluate multiple commits in a single call. + +**Purpose:** +- Build evaluation history quickly +- Evaluate recent work batch +- Analyze multiple commits for trends +- Efficient API usage for multiple evaluations + +**When to use:** +- Setting up CodeWave for existing project +- Analyzing sprint or milestone +- Building historical data +- Evaluating recent work batch + +**⏱️ Execution Time:** ~30-60 seconds per commit (runs sequentially) + +**⚠️ Limits:** Maximum 10 commits per batch + +#### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `commits` | string | βœ… Yes | - | "last:N" or comma-separated hashes (e.g., "abc123,def456,HEAD") | +| `repo_path` | string | ❌ No | current directory | Path to git repository | +| `depth` | string | ❌ No | `normal` | Analysis depth: `fast`, `normal`, or `deep` | +| `save` | boolean | ❌ No | `true` | Save evaluation results | +| `provider` | string | ❌ No | from config | LLM provider (for inline config) | +| `api_key` | string | ❌ No | from config | API key (for inline config) | +| `model` | string | ❌ No | from config | Model name (for inline config) | + +#### Batch Syntax + +**Option 1: "last:N" syntax** +```json +{ + "commits": "last:5" +} +``` +Evaluates the last 5 commits from HEAD. + +**Option 2: Comma-separated hashes** +```json +{ + "commits": "abc123,def456,HEAD,HEAD~1" +} +``` + +#### Example Requests + +**1. Evaluate last 5 commits:** +```json +{ + "commits": "last:5" +} +``` + +**2. Specific commits with fast mode:** +```json +{ + "commits": "1fac813,abc123,def456", + "depth": "fast" +} +``` + +**3. Maximum batch:** +```json +{ + "commits": "last:10", + "depth": "fast", + "save": true +} +``` + +#### Response Format + +```typescript +{ + totalCommits: number; + successful: number; + failed: number; + + results: Array<{ + commitHash: string; + success: boolean; + error?: string; + metrics?: { + overallScore: number; + codeQuality: number; + complexity: number; + technicalDebt: number; + testCoverage: number; + functionalImpact: number; + }; + }>; + + summary: { + averageScore: number; + highestScore: number; + lowestScore: number; + totalDuration: number; // seconds + }; +} +``` + +--- + +### compare_commits + +Compare metrics between two commits to show improvement or regression. + +**Purpose:** +- Track code quality changes +- Compare before/after refactoring +- Identify improvements or regressions +- Validate that changes improved quality + +**When to use:** +- After refactoring +- Comparing feature branches +- Tracking quality trends +- Code review validation + +**⚠️ Requirement:** Both commits must have been evaluated with `save: true` + +**⏱️ Execution Time:** < 1 second (no API calls) + +#### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `commit_a` | string | βœ… Yes | - | First commit (baseline) - supports partial match | +| `commit_b` | string | βœ… Yes | - | Second commit (to compare) - supports partial match | +| `repo_path` | string | ❌ No | current directory | Path to repository | + +#### Example Requests + +**1. Compare two commits:** +```json +{ + "commit_a": "1fac813", + "commit_b": "abc123" +} +``` + +**2. Compare HEAD with previous:** +```json +{ + "commit_a": "HEAD~1", + "commit_b": "HEAD" +} +``` + +#### Response Format + +```typescript +{ + commitA: { + hash: string; + message: string; + author: string; + date: string; + }; + + commitB: { + hash: string; + message: string; + author: string; + date: string; + }; + + comparison: { + overallScore: { + commitA: number; + commitB: number; + diff: number; // Positive = improvement + change: "improved" | "declined" | "unchanged"; + }; + codeQuality: { /* same structure */ }; + complexity: { /* same structure */ }; + technicalDebt: { /* same structure */ }; + testCoverage: { /* same structure */ }; + functionalImpact: { /* same structure */ }; + }; + + summary: string; // Human-readable summary +} +``` + +#### Example Response + +```json +{ + "commitA": { + "hash": "1fac813", + "message": "Initial implementation", + "author": "John Doe", + "date": "2024-01-20" + }, + "commitB": { + "hash": "abc123", + "message": "Refactor with tests", + "author": "John Doe", + "date": "2024-01-28" + }, + "comparison": { + "overallScore": { + "commitA": 7.5, + "commitB": 8.8, + "diff": +1.3, + "change": "improved" + }, + "testCoverage": { + "commitA": 6.0, + "commitB": 9.0, + "diff": +3.0, + "change": "improved" + } + }, + "summary": "Commit B shows significant improvement (+1.3 overall). Notable gains in test coverage (+3.0) and complexity management." +} +``` + +--- + +### get_team_stats + +Get aggregated code quality statistics for all authors in a repository. + +**Purpose:** +- Team-wide performance overview +- Identify high/low performers +- Track team quality trends +- Resource allocation planning + +**When to use:** +- Team retrospectives +- Sprint reviews +- Identifying training needs +- Resource planning + +**⚠️ Requirement:** Team members must have evaluated commits with `save: true` + +**⏱️ Execution Time:** < 1 second (no API calls) + +#### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `repo_path` | string | ❌ No | current directory | Path to repository | +| `since` | string | ❌ No | - | Only include evaluations after this date (YYYY-MM-DD) | +| `limit` | number | ❌ No | `10` | Maximum number of authors to return | + +#### Example Requests + +**1. Get team stats:** +```json +{} +``` + +**2. With date filter:** +```json +{ + "since": "2024-01-01" +} +``` + +**3. Top 5 contributors:** +```json +{ + "limit": 5 +} +``` + +#### Response Format + +```typescript +{ + totalAuthors: number; + totalCommitsAnalyzed: number; + dateRange: { + start: string; + end: string; + }; + + teamAverages: { + overallScore: number; + codeQuality: number; + complexity: number; + technicalDebt: number; + testCoverage: number; + functionalImpact: number; + }; + + authors: Array<{ + author: string; + commitCount: number; + averageScore: number; + trend: "improving" | "declining" | "stable"; + metrics: { /* same structure as teamAverages */ }; + }>; + + rankings: { + byCommitCount: string[]; // Author names sorted by commit count + byQuality: string[]; // Author names sorted by quality score + }; +} +``` + +#### Example Response + +```json +{ + "totalAuthors": 5, + "totalCommitsAnalyzed": 87, + "dateRange": { + "start": "2024-01-01", + "end": "2024-01-28" + }, + "teamAverages": { + "overallScore": 8.1, + "codeQuality": 8.3, + "complexity": 7.9, + "technicalDebt": 8.0, + "testCoverage": 7.8, + "functionalImpact": 8.4 + }, + "authors": [ + { + "author": "John Doe", + "commitCount": 25, + "averageScore": 8.5, + "trend": "improving", + "metrics": { + "overallScore": 8.5, + "codeQuality": 8.8, + "complexity": 8.2, + "technicalDebt": 8.3, + "testCoverage": 8.0, + "functionalImpact": 8.9 + } + } + ], + "rankings": { + "byCommitCount": ["John Doe", "Sarah Smith", "Mike Johnson"], + "byQuality": ["Sarah Smith", "John Doe", "Mike Johnson"] + } +} +``` + +--- + +### export_report + +Export evaluation report to a file in HTML, Markdown, or JSON format. + +**Purpose:** +- Save reports for later review +- Share reports with team +- Archive evaluation data +- Integrate with external tools + +**When to use:** +- Creating documentation +- Sharing with non-technical stakeholders +- Archiving important evaluations +- Integrating with CI/CD pipelines + +**⚠️ Requirement:** Commit must have been evaluated with `save: true` + +**⏱️ Execution Time:** < 1 second (no API calls) + +#### Parameters + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `commit_hash` | string | βœ… Yes | - | Commit hash to export (supports partial match) | +| `output_path` | string | βœ… Yes | - | File path where report will be saved | +| `format` | string | ❌ No | `html` | Output format: `html`, `md`, or `json` | +| `repo_path` | string | ❌ No | current directory | Path to repository | + +#### Export Formats + +| Format | Extension | Description | Use Case | +|--------|-----------|-------------|----------| +| `html` | .html | Interactive HTML with styling | Sharing with stakeholders, web viewing | +| `md` | .md | Markdown format | Documentation, GitHub/GitLab | +| `json` | .json | Structured JSON data | Programmatic access, data analysis | + +#### Example Requests + +**1. Export to HTML:** +```json +{ + "commit_hash": "1fac813", + "output_path": "/tmp/report.html" +} +``` + +**2. Export to Markdown:** +```json +{ + "commit_hash": "HEAD", + "output_path": "./docs/evaluation.md", + "format": "md" +} +``` + +**3. Export to JSON:** +```json +{ + "commit_hash": "abc123", + "output_path": "./reports/commit-abc123.json", + "format": "json" +} +``` + +#### Response Format + +```typescript +{ + success: boolean; + outputPath: string; + format: string; + fileSize: number; // bytes + commitHash: string; +} +``` + +#### Example Response + +```json +{ + "success": true, + "outputPath": "/tmp/report.html", + "format": "html", + "fileSize": 45678, + "commitHash": "1fac813abc123..." +} +``` + +--- + +## Error Handling + +CodeWave MCP tools use a sophisticated error classification system. + +### Error Types + +| Error Type | Cause | How to Fix | +|------------|-------|------------| +| `API_KEY_ERROR` | Invalid or missing API key | Check configuration, verify API key is valid | +| `RATE_LIMIT_ERROR` | Too many API requests | Wait and retry, or use different provider | +| `NETWORK_ERROR` | Network connectivity issues | Check internet connection, verify provider URL | +| `MODEL_NOT_FOUND` | Invalid model name | Check model name spelling, verify provider supports model | +| `GIT_ERROR` | Git operation failed | Verify commit hash, check repository status | +| `TOKEN_LIMIT_ERROR` | Input too large for model | Use smaller commits, or switch to model with larger context | +| `UNKNOWN_ERROR` | Unexpected error | Check logs, report issue if persistent | + +### Error Response Format + +When a tool fails, it returns: + +```typescript +{ + isError: true; + errorType: string; // One of the error types above + message: string; // Human-readable error message + suggestion: string; // Actionable suggestion to fix + details?: any; // Additional context (if available) +} +``` + +### Example Error Response + +```json +{ + "isError": true, + "errorType": "API_KEY_ERROR", + "message": "API key is invalid or missing", + "suggestion": "Run 'codewave config --init' to set up your API key, or pass provider/api_key/model inline", + "details": { + "provider": "anthropic", + "keyLength": 0 + } +} +``` + +--- + +## Response Formats + +### Success Response + +All successful tool calls return: + +```typescript +{ + content: Array<{ + type: "text"; + text: string; // JSON-encoded result + }>; + isError?: false; +} +``` + +The `text` field contains JSON with the tool-specific data. + +### Error Response + +Failed tool calls return: + +```typescript +{ + content: Array<{ + type: "text"; + text: string; // JSON-encoded error + }>; + isError: true; +} +``` + +--- + +## Additional Notes + +### Saving Evaluations + +Several tools require evaluations to be saved with `save: true`: +- `list_evaluations` +- `get_report` +- `get_author_stats` +- `generate_okr` +- `compare_commits` +- `get_team_stats` +- `export_report` + +**Where evaluations are saved:** +``` +.evaluated-commits/ +β”œβ”€β”€ 1fac813abc-2024-01-28/ +β”‚ β”œβ”€β”€ results.json +β”‚ β”œβ”€β”€ report.html +β”‚ └── conversation-transcript.md +β”œβ”€β”€ abc123def-2024-01-27/ +β”‚ └── ... +└── index.json # Fast lookup index +``` + +### API Costs + +Tools that make API calls (and cost money): +- `evaluate_commit`: 1 evaluation = ~5-10 API calls +- `batch_evaluate`: N evaluations = ~(5-10 * N) API calls +- `generate_okr`: 1 OKR generation = ~3-5 API calls + +All other tools read from saved data and make no API calls. + +### Performance Tips + +1. **Use `fast` mode** for quick checks and CI/CD +2. **Save evaluations** (`save: true`) to enable analytics +3. **Batch evaluate** instead of individual calls when possible +4. **Filter by date** (`since` parameter) to reduce processing time +5. **Use partial hashes** (7+ characters) for faster lookups + +--- + +## See Also + +- [MCP Setup Guide](./MCP_SETUP.md) - Installation and configuration +- [MCP Examples](./MCP_EXAMPLES.md) - Practical workflows and examples +- [Configuration Guide](./CONFIGURATION.md) - Detailed configuration options +- [Quick Reference](./QUICK_REFERENCE.md) - Fast command lookup + +--- + +**Last Updated**: 2026-01-28 +**CodeWave Version**: 0.1.11+ diff --git a/docs/QUICK_REFERENCE.md b/docs/QUICK_REFERENCE.md index aeeab54..8720614 100644 --- a/docs/QUICK_REFERENCE.md +++ b/docs/QUICK_REFERENCE.md @@ -353,6 +353,109 @@ codewave evaluate abc1234 --- +## MCP Quick Reference (Claude Desktop) + +### Setup Checklist + +- [ ] Install CodeWave: `npm install -g @techdebtgpt/codewave` +- [ ] Configure: `codewave config --init` +- [ ] Add to `claude_desktop_config.json` (see [MCP_SETUP.md](./MCP_SETUP.md)) +- [ ] Restart Claude Desktop +- [ ] Verify: Ask Claude "Check my CodeWave configuration" + +### Most Common MCP Tool Calls + +| What You Want | Ask Claude | Tool Used | +|---------------|------------|-----------| +| Evaluate last commit | "Evaluate my last commit" | `evaluate_commit` | +| Check config | "Check my CodeWave config" | `check_config` | +| List past evaluations | "Show my past evaluations" | `list_evaluations` | +| Get detailed report | "Show report for commit abc123" | `get_report` | +| Author statistics | "Show John's code quality stats" | `get_author_stats` | +| Generate OKRs | "Generate OKRs for Sarah" | `generate_okr` | +| Evaluate multiple | "Evaluate my last 5 commits" | `batch_evaluate` | +| Compare commits | "Compare abc123 with def456" | `compare_commits` | +| Team statistics | "Show team code quality stats" | `get_team_stats` | +| Export report | "Export commit abc123 to HTML" | `export_report` | + +### MCP vs CLI Comparison + +| Feature | MCP (Claude Desktop) | CLI | +|---------|---------------------|-----| +| **Interface** | Natural conversation | Command-line | +| **Setup** | claude_desktop_config.json | Install globally | +| **Configuration** | Same .codewave.config.json | Same .codewave.config.json | +| **Tools** | 10 tools via prompts | evaluate, batch-evaluate, config commands | +| **Output** | Claude interprets results | JSON + HTML reports | +| **Use Case** | Interactive exploration | Automation, CI/CD | +| **Learning Curve** | Low (natural language) | Medium (command syntax) | +| **Speed** | Same evaluation time | Same evaluation time | +| **Cost** | Same API costs | Same API costs | + +### Quick Start Example + +``` +1. You: "Check my CodeWave configuration" + Claude: [Shows config status] + +2. You: "Evaluate my last commit" + Claude: [Evaluates and shows results - takes 30-60 seconds] + +3. You: "That looks good. Evaluate my last 5 commits and save them" + Claude: [Batch evaluates and saves] + +4. You: "Show me my code quality statistics" + Claude: [Shows your trends and patterns] +``` + +### Common Patterns + +**Quick commit check:** +``` +"Evaluate HEAD and tell me if it's ready to merge" +``` + +**Build evaluation history:** +``` +"Evaluate my last 10 commits and save them" +``` + +**Analyze performance:** +``` +"Show me my code quality trend over the last month" +``` + +**Compare changes:** +``` +"Compare my refactoring commit abc123 with the original def456" +``` + +**Team review:** +``` +"Show me which team members have the highest quality scores" +``` + +### Tips for Better Prompts + +βœ… **Good prompts:** +- "Evaluate my last commit with deep analysis" +- "Show John's test coverage trend" +- "Find commits with high technical debt" +- "Compare this week's commits with last week's" + +❌ **Less effective prompts:** +- "Evaluate" (too vague - which commit?) +- "Stats" (which stats for whom?) +- "Compare commits" (which commits?) + +### Documentation Links + +- **[MCP_SETUP.md](./MCP_SETUP.md)** - Installation and configuration +- **[MCP_TOOLS_REFERENCE.md](./MCP_TOOLS_REFERENCE.md)** - Complete tool reference +- **[MCP_EXAMPLES.md](./MCP_EXAMPLES.md)** - Practical workflows + +--- + ## Troubleshooting Quick Fixes ### Error: "API Key not found" diff --git a/package-lock.json b/package-lock.json index eb5ffe8..a689702 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@langchain/langgraph": "^1.0.2", "@langchain/ollama": "^1.0.3", "@langchain/openai": "^1.1.3", + "@modelcontextprotocol/sdk": "^1.25.3", "@types/cli-progress": "^3.11.6", "@types/inquirer": "^9.0.9", "chalk": "^4.1.2", @@ -27,22 +28,26 @@ "langchain": "^1.1.2", "ora": "^5.4.1", "p-limit": "^5.0.0", + "picomatch": "^2.3.1", "table": "^6.9.0" }, "bin": { - "codewave": "dist/cli/index.js" + "codewave": "dist/cli/index.js", + "codewave-mcp": "dist/cli/mcp-server.js" }, "devDependencies": { "@types/glob": "^8.1.0", "@types/lodash": "^4.17.21", - "@types/node": "^20.19.24", + "@types/node": "^20.19.27", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", + "@vitest/coverage-v8": "^4.0.17", "eslint": "^8.0.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "prettier": "^3.0.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.17" }, "engines": { "node": ">=18.0.0", @@ -69,6 +74,42 @@ } } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/runtime": { "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", @@ -78,12 +119,478 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@cfworker/json-schema": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", @@ -180,6 +687,18 @@ "node": ">=18.0.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -263,6 +782,34 @@ } } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@langchain/anthropic": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@langchain/anthropic/-/anthropic-1.2.0.tgz", @@ -325,7 +872,6 @@ "resolved": "https://registry.npmjs.org/@langchain/core/-/core-1.1.1.tgz", "integrity": "sha512-vdUoj2CVbb+0Qszi8llP34vdUCfP7bfA9VoFr4Se1pFGu7VAPnk8lBnRat9IvqSxMfTvOHJSd7Rn6TUPjzKsnA==", "license": "MIT", - "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", @@ -527,6 +1073,67 @@ "@langchain/core": "^1.0.0" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.25.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.3.tgz", + "integrity": "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -578,6 +1185,374 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", + "integrity": "sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz", + "integrity": "sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz", + "integrity": "sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz", + "integrity": "sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz", + "integrity": "sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz", + "integrity": "sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz", + "integrity": "sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz", + "integrity": "sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz", + "integrity": "sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz", + "integrity": "sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz", + "integrity": "sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz", + "integrity": "sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz", + "integrity": "sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz", + "integrity": "sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz", + "integrity": "sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz", + "integrity": "sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz", + "integrity": "sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz", + "integrity": "sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz", + "integrity": "sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz", + "integrity": "sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz", + "integrity": "sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz", + "integrity": "sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz", + "integrity": "sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz", + "integrity": "sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz", + "integrity": "sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/cli-progress": { "version": "3.11.6", "resolved": "https://registry.npmjs.org/@types/cli-progress/-/cli-progress-3.11.6.tgz", @@ -587,6 +1562,20 @@ "@types/node": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/glob": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/@types/glob/-/glob-8.1.0.tgz", @@ -623,9 +1612,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.25", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", - "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", + "version": "20.19.27", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.27.tgz", + "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==", "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -702,7 +1691,6 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -863,6 +1851,148 @@ "dev": true, "license": "ISC" }, + "node_modules/@vitest/coverage-v8": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.17.tgz", + "integrity": "sha512-/6zU2FLGg0jsd+ePZcwHRy3+WpNTBBhDY56P4JTRqUN/Dp6CvOEa9HrikcQ4KfV2b2kAHUFB4dl1SuocWXSFEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.0.17", + "ast-v8-to-istanbul": "^0.3.10", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.1", + "obug": "^2.1.1", + "std-env": "^3.10.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.0.17", + "vitest": "4.0.17" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz", + "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.17", + "@vitest/utils": "4.0.17", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz", + "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.17", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.17.tgz", + "integrity": "sha512-Ah3VAYmjcEdHg6+MwFE17qyLqBHZ+ni2ScKCiW2XrlSBV4H3Z7vYfPfz7CWQ33gyu76oc0Ai36+kgLU3rfF4nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz", + "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", + "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.17", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", + "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.17.tgz", + "integrity": "sha512-RG6iy+IzQpa9SB8HAFHJ9Y+pTzI+h8553MrciN9eC6TFBErqrQaTas4vG+MVj8S4uKk8uTT2p0vgZPnTdxd96w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.17", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -875,13 +2005,50 @@ "node": ">=6.5" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -928,6 +2095,45 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -988,8 +2194,30 @@ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=8" + "engines": { + "node": ">=8" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.10.tgz", + "integrity": "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^9.0.1" } }, "node_modules/astral-regex": { @@ -1045,6 +2273,30 @@ "readable-stream": "^3.4.0" } }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -1092,6 +2344,15 @@ "ieee754": "^1.1.13" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -1105,6 +2366,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1127,6 +2404,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1273,11 +2560,63 @@ "simple-wcswidth": "^1.1.2" } }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -1292,7 +2631,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1343,6 +2681,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -1395,12 +2742,27 @@ "node": ">= 0.4" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1419,6 +2781,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -1446,6 +2815,54 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -1466,7 +2883,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -1523,7 +2939,6 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -1670,6 +3085,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -1680,6 +3105,15 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -1695,6 +3129,120 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1828,6 +3376,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -1902,6 +3471,24 @@ "node": ">= 12.20" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -1909,6 +3496,21 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -2169,6 +3771,43 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", + "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/humanize-ms": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", @@ -2295,6 +3934,15 @@ "node": ">=12.0.0" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2368,6 +4016,12 @@ "node": ">=8" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-unicode-supported": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", @@ -2384,9 +4038,56 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tiktoken": { "version": "1.0.21", "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", @@ -2396,6 +4097,13 @@ "base64-js": "^1.5.1" } }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -2435,6 +4143,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", @@ -2579,6 +4293,44 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", + "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2588,6 +4340,27 @@ "node": ">= 0.4" } }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -2688,6 +4461,25 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "license": "ISC" }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2695,6 +4487,15 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/neo-async": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", @@ -2741,6 +4542,38 @@ } } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, "node_modules/ollama": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.6.3.tgz", @@ -2750,11 +4583,22 @@ "whatwg-fetch": "^3.6.20" } }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -2968,6 +4812,15 @@ "node": ">=6" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2992,12 +4845,21 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -3008,11 +4870,24 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -3021,6 +4896,44 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -3037,7 +4950,6 @@ "integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -3061,6 +4973,19 @@ "node": ">=6.0.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3071,6 +4996,21 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3092,6 +5032,30 @@ ], "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -3175,6 +5139,67 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rollup": { + "version": "4.56.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.56.0.tgz", + "integrity": "sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.56.0", + "@rollup/rollup-android-arm64": "4.56.0", + "@rollup/rollup-darwin-arm64": "4.56.0", + "@rollup/rollup-darwin-x64": "4.56.0", + "@rollup/rollup-freebsd-arm64": "4.56.0", + "@rollup/rollup-freebsd-x64": "4.56.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.56.0", + "@rollup/rollup-linux-arm-musleabihf": "4.56.0", + "@rollup/rollup-linux-arm64-gnu": "4.56.0", + "@rollup/rollup-linux-arm64-musl": "4.56.0", + "@rollup/rollup-linux-loong64-gnu": "4.56.0", + "@rollup/rollup-linux-loong64-musl": "4.56.0", + "@rollup/rollup-linux-ppc64-gnu": "4.56.0", + "@rollup/rollup-linux-ppc64-musl": "4.56.0", + "@rollup/rollup-linux-riscv64-gnu": "4.56.0", + "@rollup/rollup-linux-riscv64-musl": "4.56.0", + "@rollup/rollup-linux-s390x-gnu": "4.56.0", + "@rollup/rollup-linux-x64-gnu": "4.56.0", + "@rollup/rollup-linux-x64-musl": "4.56.0", + "@rollup/rollup-openbsd-x64": "4.56.0", + "@rollup/rollup-openharmony-arm64": "4.56.0", + "@rollup/rollup-win32-arm64-msvc": "4.56.0", + "@rollup/rollup-win32-ia32-msvc": "4.56.0", + "@rollup/rollup-win32-x64-gnu": "4.56.0", + "@rollup/rollup-win32-x64-msvc": "4.56.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -3255,29 +5280,182 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { - "shebang-regex": "^3.0.0" + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, "engines": { - "node": ">=8" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -3341,6 +5519,39 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -3468,6 +5679,81 @@ "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -3481,6 +5767,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -3538,13 +5833,51 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3572,6 +5905,15 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3601,6 +5943,212 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", + "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.17", + "@vitest/mocker": "4.0.17", + "@vitest/pretty-format": "4.0.17", + "@vitest/runner": "4.0.17", + "@vitest/snapshot": "4.0.17", + "@vitest/spy": "4.0.17", + "@vitest/utils": "4.0.17", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.17", + "@vitest/browser-preview": "4.0.17", + "@vitest/browser-webdriverio": "4.0.17", + "@vitest/ui": "4.0.17", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/wcwidth": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", @@ -3645,7 +6193,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -3657,6 +6204,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -3706,7 +6270,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/yaml": { @@ -3741,10 +6304,18 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } } } } diff --git a/package.json b/package.json index c0f4193..5260abe 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,9 @@ "lint:fix": "eslint . --ext .ts --fix", "prettier": "prettier --write '**/*.{ts,js,json,md}'", "prettier:check": "prettier --check '**/*.{ts,js,json,md}'", - "test": "echo 'Tests not configured yet. Run: npm run build && npm run lint'", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "docs": "echo 'CodeWave Documentation' && echo '' && echo 'Start at: docs/INDEX.md' && echo 'Main docs: README.md' && echo '' && ls -lah docs/*.md | awk '{print $9, \"(\" $5 \")\"}'", "docs:readme": "echo 'Main Documentation: README.md'", "docs:quick-ref": "cat docs/QUICK_REFERENCE.md | head -50", @@ -26,20 +28,23 @@ "batch": "node ./dist/cli/index.js batch --count 3" }, "bin": { - "codewave": "./dist/cli/index.js" + "codewave": "./dist/cli/index.js", + "codewave-mcp": "./dist/cli/mcp-server.js" }, "type": "commonjs", "devDependencies": { "@types/glob": "^8.1.0", "@types/lodash": "^4.17.21", - "@types/node": "^20.19.24", + "@types/node": "^20.19.27", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", + "@vitest/coverage-v8": "^4.0.17", "eslint": "^8.0.0", "eslint-config-prettier": "^9.0.0", "eslint-plugin-prettier": "^5.0.0", "prettier": "^3.0.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^4.0.17" }, "keywords": [ "@techdebtgpt/codewave", @@ -97,6 +102,7 @@ "@langchain/langgraph": "^1.0.2", "@langchain/ollama": "^1.0.3", "@langchain/openai": "^1.1.3", + "@modelcontextprotocol/sdk": "^1.25.3", "@types/cli-progress": "^3.11.6", "@types/inquirer": "^9.0.9", "chalk": "^4.1.2", @@ -107,6 +113,7 @@ "langchain": "^1.1.2", "ora": "^5.4.1", "p-limit": "^5.0.0", + "picomatch": "^2.3.1", "table": "^6.9.0" } } diff --git a/src/agents/agent.interface.ts b/src/agents/agent.interface.ts index 3ad1edc..e9c11dc 100644 --- a/src/agents/agent.interface.ts +++ b/src/agents/agent.interface.ts @@ -20,6 +20,7 @@ export interface AgentContext { conversationHistory?: import('../types/agent.types').ConversationMessage[]; // Full conversation log vectorStore?: import('../services/diff-vector-store.service').DiffVectorStoreService; // RAG support for large diffs documentationStore?: import('../services/documentation-vector-store.service').DocumentationVectorStoreService; // Global repository documentation RAG + staticAnalysisSummary?: import('../services/static-analysis/types').StaticAnalysisSummary; // Static analysis findings for prompt injection // Multi-round conversation tracking currentRound?: number; // Current round number (0-indexed) diff --git a/src/agents/implementations/business-analyst-agent.ts b/src/agents/implementations/business-analyst-agent.ts index 193c5ac..12f55cd 100644 --- a/src/agents/implementations/business-analyst-agent.ts +++ b/src/agents/implementations/business-analyst-agent.ts @@ -10,6 +10,7 @@ import { AgentMetadata, AgentExpertise } from '../core/agent-metadata'; import { PromptContext } from '../prompts/prompt-builder.interface'; import { CombinedRAGHelper } from '../../utils/combined-rag-helper'; import { getInitialQueriesForRole } from '../../utils/gap-to-rag-query-mapper'; +import { formatForAgent, buildRound2Reference } from '../../services/static-analysis/finding-formatter'; export class BusinessAnalystAgent extends BaseAgent { // ============================================================================ @@ -113,6 +114,17 @@ ${context.commitDiff} const developerSection = isFirstRound && context.developerOverview ? `${context.developerOverview}\n\n---\n\n` : ''; + // Static analysis section (supplementary context β€” agents use their own judgment) + let staticAnalysisSection = ''; + if (context.staticAnalysisSummary && context.staticAnalysisSummary.counts.total > 0) { + if (isFirstRound) { + const formatted = formatForAgent(context.staticAnalysisSummary, this.metadata.name); + staticAnalysisSection = formatted.agentFindings; + } else { + staticAnalysisSection = buildRound2Reference(context.staticAnalysisSummary); + } + } + // Build previous round context section (Round 2+) let previousRoundContext = ''; if (!isFirstRound && context.teamConcerns && context.teamConcerns.length > 0) { @@ -166,6 +178,7 @@ ${context.teamConcerns.map((c: any, i: number) => `${i + 1}. [${c.agentName}] ${ **Files Changed:** ${filesChanged} ${contentSection} +${staticAnalysisSection} ${previousRoundContext} **Your Task:** diff --git a/src/agents/implementations/developer-author-agent.ts b/src/agents/implementations/developer-author-agent.ts index 60dd04c..5791023 100644 --- a/src/agents/implementations/developer-author-agent.ts +++ b/src/agents/implementations/developer-author-agent.ts @@ -10,6 +10,7 @@ import { AgentMetadata, AgentExpertise } from '../core/agent-metadata'; import { PromptContext } from '../prompts/prompt-builder.interface'; import { CombinedRAGHelper } from '../../utils/combined-rag-helper'; import { getInitialQueriesForRole } from '../../utils/gap-to-rag-query-mapper'; +import { formatForAgent, buildRound2Reference } from '../../services/static-analysis/finding-formatter'; export class DeveloperAuthorAgent extends BaseAgent { // ============================================================================ // AGENT IDENTITY & EXPERTISE @@ -111,6 +112,17 @@ ${context.commitDiff} const developerSection = isFirstRound && context.developerOverview ? `${context.developerOverview}\n\n---\n\n` : ''; + // Static analysis section (supplementary context β€” agents use their own judgment) + let staticAnalysisSection = ''; + if (context.staticAnalysisSummary && context.staticAnalysisSummary.counts.total > 0) { + if (isFirstRound) { + const formatted = formatForAgent(context.staticAnalysisSummary, this.metadata.name); + staticAnalysisSection = formatted.agentFindings; + } else { + staticAnalysisSection = buildRound2Reference(context.staticAnalysisSummary); + } + } + // Build previous round context section (Round 2+) let previousRoundContext = ''; if (!isFirstRound && context.teamConcerns && context.teamConcerns.length > 0) { @@ -161,6 +173,7 @@ ${context.teamConcerns.map((c: any, i: number) => `${i + 1}. [${c.agentName}] ${ **Files Changed:** ${filesChanged} ${contentSection} +${staticAnalysisSection} ${previousRoundContext} **Your Task:** diff --git a/src/agents/implementations/developer-reviewer-agent.ts b/src/agents/implementations/developer-reviewer-agent.ts index cea69b7..b3a6e33 100644 --- a/src/agents/implementations/developer-reviewer-agent.ts +++ b/src/agents/implementations/developer-reviewer-agent.ts @@ -10,6 +10,7 @@ import { AgentMetadata, AgentExpertise } from '../core/agent-metadata'; import { PromptContext } from '../prompts/prompt-builder.interface'; import { CombinedRAGHelper } from '../../utils/combined-rag-helper'; import { getInitialQueriesForRole } from '../../utils/gap-to-rag-query-mapper'; +import { formatForAgent, buildRound2Reference } from '../../services/static-analysis/finding-formatter'; export class DeveloperReviewerAgent extends BaseAgent { // ============================================================================ // AGENT IDENTITY & EXPERTISE @@ -112,6 +113,17 @@ ${context.commitDiff} const developerSection = isFirstRound && context.developerOverview ? `${context.developerOverview}\n\n---\n\n` : ''; + // Static analysis section (supplementary context β€” agents use their own judgment) + let staticAnalysisSection = ''; + if (context.staticAnalysisSummary && context.staticAnalysisSummary.counts.total > 0) { + if (isFirstRound) { + const formatted = formatForAgent(context.staticAnalysisSummary, this.metadata.name); + staticAnalysisSection = formatted.agentFindings; + } else { + staticAnalysisSection = buildRound2Reference(context.staticAnalysisSummary); + } + } + // Build previous round context section (Round 2+) let previousRoundContext = ''; if (!isFirstRound && context.teamConcerns && context.teamConcerns.length > 0) { @@ -164,6 +176,7 @@ ${context.teamConcerns.map((c: any, i: number) => `${i + 1}. [${c.agentName}] ${ **Files Changed:** ${filesChanged} ${contentSection} +${staticAnalysisSection} ${previousRoundContext} **Your Task:** diff --git a/src/agents/implementations/sdet-agent.ts b/src/agents/implementations/sdet-agent.ts index 66fc476..569111f 100644 --- a/src/agents/implementations/sdet-agent.ts +++ b/src/agents/implementations/sdet-agent.ts @@ -10,6 +10,7 @@ import { AgentMetadata, AgentExpertise } from '../core/agent-metadata'; import { PromptContext } from '../prompts/prompt-builder.interface'; import { CombinedRAGHelper } from '../../utils/combined-rag-helper'; import { getInitialQueriesForRole } from '../../utils/gap-to-rag-query-mapper'; +import { formatForAgent, buildRound2Reference } from '../../services/static-analysis/finding-formatter'; export class SDETAgent extends BaseAgent { // ============================================================================ @@ -110,6 +111,17 @@ ${context.commitDiff} const developerSection = isFirstRound && context.developerOverview ? `${context.developerOverview}\n\n---\n\n` : ''; + // Static analysis section (supplementary context β€” agents use their own judgment) + let staticAnalysisSection = ''; + if (context.staticAnalysisSummary && context.staticAnalysisSummary.counts.total > 0) { + if (isFirstRound) { + const formatted = formatForAgent(context.staticAnalysisSummary, this.metadata.name); + staticAnalysisSection = formatted.agentFindings; + } else { + staticAnalysisSection = buildRound2Reference(context.staticAnalysisSummary); + } + } + // Build previous round context section (Round 2+) let previousRoundContext = ''; if (!isFirstRound && context.teamConcerns && context.teamConcerns.length > 0) { @@ -162,6 +174,7 @@ ${context.teamConcerns.map((c: any, i: number) => `${i + 1}. [${c.agentName}] ${ **Files Changed:** ${filesChanged} ${contentSection} +${staticAnalysisSection} ${previousRoundContext} **Your Task:** diff --git a/src/agents/implementations/senior-architect-agent.ts b/src/agents/implementations/senior-architect-agent.ts index 2cb648a..429ed1a 100644 --- a/src/agents/implementations/senior-architect-agent.ts +++ b/src/agents/implementations/senior-architect-agent.ts @@ -10,6 +10,7 @@ import { AgentMetadata, AgentExpertise } from '../core/agent-metadata'; import { PromptContext } from '../prompts/prompt-builder.interface'; import { CombinedRAGHelper } from '../../utils/combined-rag-helper'; import { getInitialQueriesForRole } from '../../utils/gap-to-rag-query-mapper'; +import { formatForAgent, buildRound2Reference } from '../../services/static-analysis/finding-formatter'; export class SeniorArchitectAgent extends BaseAgent { // ============================================================================ // AGENT IDENTITY & EXPERTISE @@ -112,6 +113,17 @@ ${context.commitDiff} const developerSection = isFirstRound && context.developerOverview ? `${context.developerOverview}\n\n---\n\n` : ''; + // Static analysis section (supplementary context β€” agents use their own judgment) + let staticAnalysisSection = ''; + if (context.staticAnalysisSummary && context.staticAnalysisSummary.counts.total > 0) { + if (isFirstRound) { + const formatted = formatForAgent(context.staticAnalysisSummary, this.metadata.name); + staticAnalysisSection = formatted.agentFindings; + } else { + staticAnalysisSection = buildRound2Reference(context.staticAnalysisSummary); + } + } + // Build previous round context section (Round 2+) let previousRoundContext = ''; if (!isFirstRound && context.teamConcerns && context.teamConcerns.length > 0) { @@ -164,6 +176,7 @@ ${context.teamConcerns.map((c: any, i: number) => `${i + 1}. [${c.agentName}] ${ **Files Changed:** ${filesChanged} ${contentSection} +${staticAnalysisSection} ${previousRoundContext} **Your Task:** diff --git a/src/common/logger/__tests__/logger.service.test.ts b/src/common/logger/__tests__/logger.service.test.ts new file mode 100644 index 0000000..c9da417 --- /dev/null +++ b/src/common/logger/__tests__/logger.service.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { LoggerService } from '../logger.service'; +import { CaptureTransport } from '../transports/capture.transport'; +import { SilentTransport } from '../transports/silent.transport'; +import { CliTransport } from '../transports/cli.transport'; +import { LogEntry } from '../types'; + +describe('LoggerService', () => { + let capture: CaptureTransport; + + beforeEach(() => { + capture = new CaptureTransport(); + LoggerService.setTransport(capture); + LoggerService.setLevel('debug'); // Allow all levels by default in tests + }); + + afterEach(() => { + LoggerService.resetForTests(); + }); + + describe('basic logging', () => { + it('should log debug messages', () => { + LoggerService.debug('test debug'); + expect(capture.entries).toHaveLength(1); + expect(capture.entries[0].level).toBe('debug'); + expect(capture.entries[0].message).toBe('test debug'); + }); + + it('should log info messages', () => { + LoggerService.info('test info'); + expect(capture.entries).toHaveLength(1); + expect(capture.entries[0].level).toBe('info'); + }); + + it('should log warn messages', () => { + LoggerService.warn('test warn'); + expect(capture.entries).toHaveLength(1); + expect(capture.entries[0].level).toBe('warn'); + }); + + it('should log error messages', () => { + LoggerService.error('test error'); + expect(capture.entries).toHaveLength(1); + expect(capture.entries[0].level).toBe('error'); + }); + + it('should include category when provided', () => { + LoggerService.info('msg', { category: 'static-analysis' }); + expect(capture.entries[0].category).toBe('static-analysis'); + }); + + it('should include data when provided', () => { + LoggerService.info('msg', { data: { count: 5 } }); + expect(capture.entries[0].data).toEqual({ count: 5 }); + }); + + it('should include timestamp', () => { + const before = Date.now(); + LoggerService.info('msg'); + const after = Date.now(); + expect(capture.entries[0].timestamp).toBeGreaterThanOrEqual(before); + expect(capture.entries[0].timestamp).toBeLessThanOrEqual(after); + }); + }); + + describe('level filtering', () => { + it('should drop debug when level is info', () => { + LoggerService.setLevel('info'); + LoggerService.debug('dropped'); + LoggerService.info('kept'); + expect(capture.entries).toHaveLength(1); + expect(capture.entries[0].message).toBe('kept'); + }); + + it('should drop debug and info when level is warn', () => { + LoggerService.setLevel('warn'); + LoggerService.debug('dropped'); + LoggerService.info('dropped'); + LoggerService.warn('kept'); + LoggerService.error('kept'); + expect(capture.entries).toHaveLength(2); + }); + + it('should only keep error when level is error', () => { + LoggerService.setLevel('error'); + LoggerService.debug('dropped'); + LoggerService.info('dropped'); + LoggerService.warn('dropped'); + LoggerService.error('kept'); + expect(capture.entries).toHaveLength(1); + expect(capture.entries[0].level).toBe('error'); + }); + + it('should keep all when level is debug', () => { + LoggerService.setLevel('debug'); + LoggerService.debug('d'); + LoggerService.info('i'); + LoggerService.warn('w'); + LoggerService.error('e'); + expect(capture.entries).toHaveLength(4); + }); + }); + + describe('transport swapping', () => { + it('should use the last set transport', () => { + const capture2 = new CaptureTransport(); + LoggerService.setTransport(capture2); + LoggerService.info('goes to capture2'); + expect(capture.entries).toHaveLength(0); + expect(capture2.entries).toHaveLength(1); + }); + + it('should produce no output with SilentTransport', () => { + LoggerService.setTransport(new SilentTransport()); + LoggerService.info('silent'); + LoggerService.error('silent'); + // No error thrown, no output β€” just verifying it doesn't crash + }); + }); + + describe('resetForTests', () => { + it('should reset level to info', () => { + LoggerService.setLevel('error'); + LoggerService.resetForTests(); + // After reset, level should be info β€” reattach capture to verify + LoggerService.setTransport(capture); + LoggerService.debug('dropped after reset'); + LoggerService.info('kept after reset'); + expect(capture.entries).toHaveLength(1); + expect(capture.entries[0].message).toBe('kept after reset'); + }); + }); +}); + +describe('CaptureTransport', () => { + let capture: CaptureTransport; + + beforeEach(() => { + capture = new CaptureTransport(); + }); + + it('should capture entries', () => { + const entry: LogEntry = { + level: 'info', + message: 'test', + timestamp: Date.now(), + }; + capture.write(entry); + expect(capture.entries).toHaveLength(1); + expect(capture.entries[0]).toBe(entry); + }); + + it('should clear entries', () => { + capture.write({ level: 'info', message: 'a', timestamp: Date.now() }); + capture.write({ level: 'warn', message: 'b', timestamp: Date.now() }); + expect(capture.entries).toHaveLength(2); + capture.clear(); + expect(capture.entries).toHaveLength(0); + }); + + it('should filter by level', () => { + capture.write({ level: 'info', message: 'a', timestamp: Date.now() }); + capture.write({ level: 'warn', message: 'b', timestamp: Date.now() }); + capture.write({ level: 'info', message: 'c', timestamp: Date.now() }); + expect(capture.getByLevel('info')).toHaveLength(2); + expect(capture.getByLevel('warn')).toHaveLength(1); + expect(capture.getByLevel('error')).toHaveLength(0); + }); + + it('should filter by category', () => { + capture.write({ + level: 'info', + message: 'a', + category: 'static-analysis', + timestamp: Date.now(), + }); + capture.write({ + level: 'info', + message: 'b', + category: 'vectorstore', + timestamp: Date.now(), + }); + capture.write({ + level: 'warn', + message: 'c', + category: 'static-analysis', + timestamp: Date.now(), + }); + expect(capture.getByCategory('static-analysis')).toHaveLength(2); + expect(capture.getByCategory('vectorstore')).toHaveLength(1); + expect(capture.getByCategory('unknown')).toHaveLength(0); + }); +}); + +describe('CliTransport', () => { + it('should write to process.stderr', () => { + const transport = new CliTransport(); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + transport.write({ + level: 'info', + message: 'test output', + timestamp: Date.now(), + }); + + expect(stderrSpy).toHaveBeenCalledTimes(1); + const output = stderrSpy.mock.calls[0][0] as string; + expect(output).toContain('test output'); + expect(output).toContain('[info]'); + expect(output.endsWith('\n')).toBe(true); + }); + + it('should include category in output', () => { + const transport = new CliTransport(); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + transport.write({ + level: 'warn', + message: 'timeout', + category: 'static-analysis', + timestamp: Date.now(), + }); + + const output = stderrSpy.mock.calls[0][0] as string; + expect(output).toContain('[static-analysis]'); + expect(output).toContain('[warn]'); + expect(output).toContain('timeout'); + }); + + it('should not write to stdout', () => { + const transport = new CliTransport(); + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + transport.write({ level: 'info', message: 'test', timestamp: Date.now() }); + + expect(stdoutSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/common/logger/index.ts b/src/common/logger/index.ts new file mode 100644 index 0000000..78d75da --- /dev/null +++ b/src/common/logger/index.ts @@ -0,0 +1,6 @@ +export { LoggerService } from './logger.service'; +export type { LogLevel, LogEntry, LogTransport } from './types'; +export { LOG_LEVEL_RANK } from './types'; +export { CliTransport } from './transports/cli.transport'; +export { SilentTransport } from './transports/silent.transport'; +export { CaptureTransport } from './transports/capture.transport'; diff --git a/src/common/logger/logger.service.ts b/src/common/logger/logger.service.ts new file mode 100644 index 0000000..52503f3 --- /dev/null +++ b/src/common/logger/logger.service.ts @@ -0,0 +1,71 @@ +import { LogLevel, LogEntry, LogTransport, LOG_LEVEL_RANK } from './types'; +import { CliTransport } from './transports/cli.transport'; + +export class LoggerService { + private static transport: LogTransport = new CliTransport(); + private static level: LogLevel = 'info'; + + /** Swap the active transport (call once at entry point startup) */ + static setTransport(transport: LogTransport): void { + LoggerService.transport = transport; + } + + /** Set minimum log level (entries below this are dropped) */ + static setLevel(level: LogLevel): void { + LoggerService.level = level; + } + + /** Reset to defaults β€” used in tests to prevent cross-test state leakage */ + static resetForTests(): void { + LoggerService.transport = new CliTransport(); + LoggerService.level = 'info'; + } + + static debug( + message: string, + opts?: { category?: string; data?: Record } + ): void { + LoggerService.log('debug', message, opts); + } + + static info( + message: string, + opts?: { category?: string; data?: Record } + ): void { + LoggerService.log('info', message, opts); + } + + static warn( + message: string, + opts?: { category?: string; data?: Record } + ): void { + LoggerService.log('warn', message, opts); + } + + static error( + message: string, + opts?: { category?: string; data?: Record } + ): void { + LoggerService.log('error', message, opts); + } + + private static log( + level: LogLevel, + message: string, + opts?: { category?: string; data?: Record } + ): void { + if (LOG_LEVEL_RANK[level] < LOG_LEVEL_RANK[LoggerService.level]) { + return; + } + + const entry: LogEntry = { + level, + message, + category: opts?.category, + data: opts?.data, + timestamp: Date.now(), + }; + + LoggerService.transport.write(entry); + } +} diff --git a/src/common/logger/transports/capture.transport.ts b/src/common/logger/transports/capture.transport.ts new file mode 100644 index 0000000..95dfd7e --- /dev/null +++ b/src/common/logger/transports/capture.transport.ts @@ -0,0 +1,25 @@ +import { LogEntry, LogLevel, LogTransport } from '../types'; + +/** + * Capture Transport β€” stores entries in an array. Used in tests. + * Enables asserting on log output without mocking console. + */ +export class CaptureTransport implements LogTransport { + readonly entries: LogEntry[] = []; + + write(entry: LogEntry): void { + this.entries.push(entry); + } + + clear(): void { + this.entries.length = 0; + } + + getByLevel(level: LogLevel): LogEntry[] { + return this.entries.filter((e) => e.level === level); + } + + getByCategory(category: string): LogEntry[] { + return this.entries.filter((e) => e.category === category); + } +} diff --git a/src/common/logger/transports/cli.transport.ts b/src/common/logger/transports/cli.transport.ts new file mode 100644 index 0000000..f6aaa57 --- /dev/null +++ b/src/common/logger/transports/cli.transport.ts @@ -0,0 +1,27 @@ +import chalk from 'chalk'; +import { LogEntry, LogLevel, LogTransport } from '../types'; + +/** + * CLI Transport β€” writes human-readable output to process.stderr. + * Uses chalk for colors. Never touches stdout. + */ +export class CliTransport implements LogTransport { + write(entry: LogEntry): void { + const prefix = this.formatPrefix(entry.level); + const category = entry.category ? ` [${entry.category}]` : ''; + process.stderr.write(`${prefix}${category} ${entry.message}\n`); + } + + private formatPrefix(level: LogLevel): string { + switch (level) { + case 'debug': + return chalk.gray('[debug]'); + case 'info': + return chalk.cyan('[info]'); + case 'warn': + return chalk.yellow('[warn]'); + case 'error': + return chalk.red('[error]'); + } + } +} diff --git a/src/common/logger/transports/silent.transport.ts b/src/common/logger/transports/silent.transport.ts new file mode 100644 index 0000000..2f9dd23 --- /dev/null +++ b/src/common/logger/transports/silent.transport.ts @@ -0,0 +1,11 @@ +import { LogEntry, LogTransport } from '../types'; + +/** + * Silent Transport β€” noop. Used when LoggerService output must be suppressed + * (e.g., MCP mode). Stdout stays 100% clean for JSON-RPC protocol. + */ +export class SilentTransport implements LogTransport { + write(_entry: LogEntry): void { + // Intentionally empty + } +} diff --git a/src/common/logger/types.ts b/src/common/logger/types.ts new file mode 100644 index 0000000..9d53041 --- /dev/null +++ b/src/common/logger/types.ts @@ -0,0 +1,24 @@ +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export const LOG_LEVEL_RANK: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +export interface LogEntry { + level: LogLevel; + message: string; + category?: string; + data?: Record; + timestamp: number; +} + +/** + * Transport interface β€” determines WHERE and HOW logs are rendered. + * Swap transports without changing callers (Strategy pattern). + */ +export interface LogTransport { + write(entry: LogEntry): void; +} diff --git a/src/config/__tests__/config-loader.validation.test.ts b/src/config/__tests__/config-loader.validation.test.ts new file mode 100644 index 0000000..0cad6c6 --- /dev/null +++ b/src/config/__tests__/config-loader.validation.test.ts @@ -0,0 +1,189 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { loadConfig } from '../config-loader'; +import { STATIC_ANALYSIS_DEFAULT_EXCLUDED_PATHS } from '../default-config'; +import { ConfigValidationError } from '../config-validator'; + +describe('loadConfig validation behavior', () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codewave-config-loader-')); + }); + + afterEach(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('loads valid config file successfully', () => { + const configPath = path.join(tempRoot, '.codewave.config.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + llm: { + provider: 'openai', + }, + staticAnalysis: { + semgrep: { + timeout: 30000, + }, + qlty: { + smells: { + includeDuplication: false, + }, + }, + }, + }) + ); + + const loaded = loadConfig(configPath); + expect(loaded).not.toBeNull(); + expect(loaded?.staticAnalysis?.semgrep.timeout).toBe(30000); + expect(loaded?.staticAnalysis?.autoInstall).toBe(false); + expect(loaded?.staticAnalysis?.autoInstallSystemDeps).toBe(false); + expect(loaded?.staticAnalysis?.qlty.smells.enabled).toBe(true); + expect(loaded?.staticAnalysis?.qlty.smells.timeout).toBe(60000); + expect(loaded?.staticAnalysis?.qlty.smells.includeDuplication).toBe(false); + expect(loaded?.staticAnalysis?.excludedPaths).toContain('docs/**'); + expect(loaded?.staticAnalysis?.excludedPaths).toContain('**/package-lock.json'); + expect(loaded?.staticAnalysis?.excludedPaths).toContain('**/Cargo.lock'); + expect(loaded?.staticAnalysis?.excludedPaths).toContain('**/Pipfile.lock'); + expect(loaded?.staticAnalysis?.excludedPaths).toContain('**/.terraform.lock.hcl'); + }); + + it('supports overriding autoInstallSystemDeps', () => { + const configPath = path.join(tempRoot, '.codewave.config.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + staticAnalysis: { + autoInstallSystemDeps: true, + }, + }) + ); + + const loaded = loadConfig(configPath); + expect(loaded?.staticAnalysis?.autoInstallSystemDeps).toBe(true); + }); + + it('throws ConfigValidationError for semantic invalid staticAnalysis config', () => { + const configPath = path.join(tempRoot, '.codewave.config.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + staticAnalysis: { + minSeverity: 'critical', + semgrep: { + timeout: 0, + }, + }, + }) + ); + + expect(() => loadConfig(configPath)).toThrow(ConfigValidationError); + }); + + it('returns null when config file is missing', () => { + const configPath = path.join(tempRoot, '.codewave.config.json'); + const loaded = loadConfig(configPath); + expect(loaded).toBeNull(); + }); + + it('throws parse error for malformed json', () => { + const configPath = path.join(tempRoot, '.codewave.config.json'); + fs.writeFileSync(configPath, '{"staticAnalysis": {'); + + expect(() => loadConfig(configPath)).toThrow( + /Failed to parse config file at .*\.codewave\.config\.json/ + ); + }); + + describe('staticAnalysis.excludedPaths merge behavior', () => { + it('merges user excludedPaths on top of defaults instead of replacing them', () => { + const configPath = path.join(tempRoot, '.codewave.config.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + staticAnalysis: { + excludedPaths: ['my-custom-dir/**', 'legacy/**'], + }, + }) + ); + + const loaded = loadConfig(configPath); + const paths = loaded?.staticAnalysis?.excludedPaths ?? []; + + // User patterns are present + expect(paths).toContain('my-custom-dir/**'); + expect(paths).toContain('legacy/**'); + + // Defaults are still present + expect(paths).toContain('node_modules/**'); + expect(paths).toContain('dist/**'); + expect(paths).toContain('docs/**'); + expect(paths).toContain('**/package-lock.json'); + expect(paths).toContain('**/Cargo.lock'); + }); + + it('preserves all defaults when user provides no excludedPaths', () => { + const configPath = path.join(tempRoot, '.codewave.config.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + staticAnalysis: { + semgrep: { timeout: 30000 }, + }, + }) + ); + + const loaded = loadConfig(configPath); + const paths = loaded?.staticAnalysis?.excludedPaths ?? []; + + for (const defaultPattern of STATIC_ANALYSIS_DEFAULT_EXCLUDED_PATHS) { + expect(paths).toContain(defaultPattern); + } + }); + + it('deduplicates when user provides a pattern that already exists in defaults', () => { + const configPath = path.join(tempRoot, '.codewave.config.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + staticAnalysis: { + excludedPaths: ['node_modules/**', 'dist/**', 'my-extra/**'], + }, + }) + ); + + const loaded = loadConfig(configPath); + const paths = loaded?.staticAnalysis?.excludedPaths ?? []; + + const nodeModulesCount = paths.filter((p: string) => p === 'node_modules/**').length; + expect(nodeModulesCount).toBe(1); + + const distCount = paths.filter((p: string) => p === 'dist/**').length; + expect(distCount).toBe(1); + + expect(paths).toContain('my-extra/**'); + }); + + it('keeps all defaults when user sets excludedPaths to an empty array', () => { + const configPath = path.join(tempRoot, '.codewave.config.json'); + fs.writeFileSync( + configPath, + JSON.stringify({ + staticAnalysis: { + excludedPaths: [], + }, + }) + ); + + const loaded = loadConfig(configPath); + const paths = loaded?.staticAnalysis?.excludedPaths ?? []; + + expect(paths.length).toBe(STATIC_ANALYSIS_DEFAULT_EXCLUDED_PATHS.length); + }); + }); +}); diff --git a/src/config/__tests__/config-validator.test.ts b/src/config/__tests__/config-validator.test.ts new file mode 100644 index 0000000..1ba4a3a --- /dev/null +++ b/src/config/__tests__/config-validator.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from 'vitest'; +import { AppConfig } from '../config.interface'; +import { DEFAULT_CONFIG } from '../default-config'; +import { ConfigValidationError, validateConfig } from '../config-validator'; + +function createConfig(overrides: Partial = {}): AppConfig { + const base = JSON.parse(JSON.stringify(DEFAULT_CONFIG)) as AppConfig; + return { + ...base, + ...overrides, + }; +} + +function expectValidationError(config: AppConfig): ConfigValidationError { + try { + validateConfig(config); + throw new Error('Expected validateConfig to throw'); + } catch (error) { + expect(error).toBeInstanceOf(ConfigValidationError); + return error as ConfigValidationError; + } +} + +describe('validateConfig (staticAnalysis)', () => { + it('passes valid configuration', () => { + const config = createConfig(); + expect(() => validateConfig(config)).not.toThrow(); + }); + + it('rejects invalid minSeverity', () => { + const config = createConfig(); + if (config.staticAnalysis) { + (config.staticAnalysis as any).minSeverity = 'critical'; + } + + const error = expectValidationError(config); + expect(error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'staticAnalysis.minSeverity', + code: 'invalid_enum', + }), + ]) + ); + }); + + it('rejects invalid timeout values', () => { + const config = createConfig(); + if (config.staticAnalysis) { + (config.staticAnalysis.semgrep as any).timeout = 0; + (config.staticAnalysis.qlty as any).timeout = 10.5; + (config.staticAnalysis.qlty.smells as any).timeout = -1; + } + + const error = expectValidationError(config); + expect(error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'staticAnalysis.semgrep.timeout', + }), + expect.objectContaining({ + path: 'staticAnalysis.qlty.timeout', + }), + expect.objectContaining({ + path: 'staticAnalysis.qlty.smells.timeout', + }), + ]) + ); + }); + + it('rejects invalid qlty.smells boolean flags', () => { + const config = createConfig(); + if (config.staticAnalysis) { + (config.staticAnalysis.qlty.smells as any).enabled = 'yes'; + (config.staticAnalysis.qlty.smells as any).includeDuplication = 1; + } + + const error = expectValidationError(config); + expect(error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'staticAnalysis.qlty.smells.enabled', + code: 'invalid_type', + }), + expect.objectContaining({ + path: 'staticAnalysis.qlty.smells.includeDuplication', + code: 'invalid_type', + }), + ]) + ); + }); + + it('rejects invalid autoInstallSystemDeps flag type', () => { + const config = createConfig(); + if (config.staticAnalysis) { + (config.staticAnalysis as any).autoInstallSystemDeps = 'yes'; + } + + const error = expectValidationError(config); + expect(error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'staticAnalysis.autoInstallSystemDeps', + code: 'invalid_type', + }), + ]) + ); + }); + + it('rejects empty semgrep.rules when semgrep is enabled', () => { + const config = createConfig(); + if (config.staticAnalysis) { + config.staticAnalysis.semgrep.enabled = true; + config.staticAnalysis.semgrep.rules = []; + } + + const error = expectValidationError(config); + expect(error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'staticAnalysis.semgrep.rules', + code: 'required_non_empty_array', + }), + ]) + ); + }); + + it('rejects invalid maxFindings values', () => { + const config = createConfig(); + if (config.staticAnalysis) { + (config.staticAnalysis as any).maxFindings = 5001; + } + const tooHighError = expectValidationError(config); + expect(tooHighError.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'staticAnalysis.maxFindings', + code: 'invalid_range', + }), + ]) + ); + + const secondConfig = createConfig(); + if (secondConfig.staticAnalysis) { + (secondConfig.staticAnalysis as any).maxFindings = 1.2; + } + const nonIntegerError = expectValidationError(secondConfig); + expect(nonIntegerError.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'staticAnalysis.maxFindings', + code: 'invalid_integer', + }), + ]) + ); + }); + + it('collects multiple validation issues in one error', () => { + const config = createConfig(); + if (config.staticAnalysis) { + (config.staticAnalysis as any).minSeverity = 'critical'; + (config.staticAnalysis.semgrep as any).timeout = -5; + config.staticAnalysis.semgrep.rules = ['']; + } + + const error = expectValidationError(config); + expect(error.issues.length).toBeGreaterThanOrEqual(3); + expect(error.issues.map((issue) => issue.path)).toEqual( + expect.arrayContaining([ + 'staticAnalysis.minSeverity', + 'staticAnalysis.semgrep.timeout', + 'staticAnalysis.semgrep.rules[0]', + ]) + ); + }); +}); diff --git a/src/config/config-loader.ts b/src/config/config-loader.ts index a944f76..0003e4a 100644 --- a/src/config/config-loader.ts +++ b/src/config/config-loader.ts @@ -3,8 +3,9 @@ import fs from 'fs'; import path from 'path'; -import { DEFAULT_CONFIG } from './default-config'; +import { DEFAULT_CONFIG, STATIC_ANALYSIS_DEFAULT_EXCLUDED_PATHS } from './default-config'; import { AppConfig } from './config.interface'; +import { ConfigValidationError, validateConfig } from './config-validator'; /** * Load configuration from file @@ -22,18 +23,76 @@ export function loadConfig(configPath?: string): AppConfig | null { try { const fileContent = fs.readFileSync(resolvedPath, 'utf-8'); const userConfig: Partial = JSON.parse(fileContent); + const documentationDefaults: NonNullable = + DEFAULT_CONFIG.documentation ?? { enabled: true, patterns: [] }; + const staticAnalysisDefaults: NonNullable = DEFAULT_CONFIG.staticAnalysis ?? { + enabled: true, + autoInstall: false, + autoInstallSystemDeps: false, + excludedPaths: [...STATIC_ANALYSIS_DEFAULT_EXCLUDED_PATHS], + semgrep: { + enabled: true, + rules: ['auto'], + timeout: 60000, + }, + qlty: { + enabled: true, + timeout: 60000, + smells: { + enabled: true, + timeout: 60000, + includeDuplication: true, + }, + }, + maxFindings: 500, + minSeverity: 'warning', + }; - // Deep merge with defaults + // Deep merge with defaults (section-level spread, nested for sub-objects) const merged: AppConfig = { apiKeys: { ...DEFAULT_CONFIG.apiKeys, ...(userConfig.apiKeys || {}) }, llm: { ...DEFAULT_CONFIG.llm, ...(userConfig.llm || {}) }, agents: { ...DEFAULT_CONFIG.agents, ...(userConfig.agents || {}) }, output: { ...DEFAULT_CONFIG.output, ...(userConfig.output || {}) }, tracing: { ...DEFAULT_CONFIG.tracing, ...(userConfig.tracing || {}) }, + documentation: userConfig.documentation + ? { ...documentationDefaults, ...userConfig.documentation } + : documentationDefaults, + staticAnalysis: userConfig.staticAnalysis + ? { + ...staticAnalysisDefaults, + ...userConfig.staticAnalysis, + // User excludedPaths are additive β€” merged on top of defaults, not replacing them. + excludedPaths: [ + ...new Set([ + ...staticAnalysisDefaults.excludedPaths, + ...(userConfig.staticAnalysis.excludedPaths || []), + ]), + ], + semgrep: { + ...staticAnalysisDefaults.semgrep, + ...(userConfig.staticAnalysis.semgrep || {}), + }, + qlty: { + ...staticAnalysisDefaults.qlty, + ...(userConfig.staticAnalysis.qlty || {}), + smells: { + ...staticAnalysisDefaults.qlty.smells, + ...(userConfig.staticAnalysis.qlty?.smells || {}), + }, + }, + } + : staticAnalysisDefaults, }; + validateConfig(merged); + return merged; } catch (err) { + if (err instanceof ConfigValidationError) { + throw err; + } + throw new Error( `Failed to parse config file at ${resolvedPath}: ${err instanceof Error ? err.message : String(err)}` ); diff --git a/src/config/config-validator.ts b/src/config/config-validator.ts new file mode 100644 index 0000000..98780e9 --- /dev/null +++ b/src/config/config-validator.ts @@ -0,0 +1,253 @@ +import { AppConfig } from './config.interface'; + +const STATIC_ANALYSIS_ROOT_PATH = 'staticAnalysis'; +const VALID_MIN_SEVERITY = ['error', 'warning', 'info'] as const; + +export interface ConfigValidationIssue { + path: string; + code: string; + message: string; + value?: unknown; +} + +export class ConfigValidationError extends Error { + readonly issues: ConfigValidationIssue[]; + + constructor(issues: ConfigValidationIssue[]) { + super('Invalid configuration: staticAnalysis contains invalid values'); + this.name = 'ConfigValidationError'; + this.issues = issues; + } +} + +export function validateConfig(config: AppConfig): void { + const issues: ConfigValidationIssue[] = []; + const staticAnalysis = config?.staticAnalysis; + + if (!staticAnalysis) { + return; + } + + validateBoolean(issues, `${STATIC_ANALYSIS_ROOT_PATH}.enabled`, staticAnalysis.enabled); + validateBoolean(issues, `${STATIC_ANALYSIS_ROOT_PATH}.autoInstall`, staticAnalysis.autoInstall); + validateBoolean( + issues, + `${STATIC_ANALYSIS_ROOT_PATH}.autoInstallSystemDeps`, + staticAnalysis.autoInstallSystemDeps + ); + validateNonEmptyStringArray( + issues, + `${STATIC_ANALYSIS_ROOT_PATH}.excludedPaths`, + staticAnalysis.excludedPaths + ); + + validateBoolean( + issues, + `${STATIC_ANALYSIS_ROOT_PATH}.semgrep.enabled`, + staticAnalysis.semgrep?.enabled + ); + validateNonEmptyStringArray( + issues, + `${STATIC_ANALYSIS_ROOT_PATH}.semgrep.rules`, + staticAnalysis.semgrep?.rules + ); + if (staticAnalysis.semgrep?.enabled === true && Array.isArray(staticAnalysis.semgrep?.rules)) { + const nonEmptyRuleCount = staticAnalysis.semgrep.rules + .filter((rule): rule is string => typeof rule === 'string') + .map((rule) => rule.trim()) + .filter((rule) => rule.length > 0).length; + + if (nonEmptyRuleCount === 0) { + issues.push({ + path: `${STATIC_ANALYSIS_ROOT_PATH}.semgrep.rules`, + code: 'required_non_empty_array', + message: 'must include at least one non-empty rule when semgrep is enabled', + value: staticAnalysis.semgrep.rules, + }); + } + } + validateIntegerMin( + issues, + `${STATIC_ANALYSIS_ROOT_PATH}.semgrep.timeout`, + staticAnalysis.semgrep?.timeout, + 1 + ); + + validateBoolean( + issues, + `${STATIC_ANALYSIS_ROOT_PATH}.qlty.enabled`, + staticAnalysis.qlty?.enabled + ); + validateIntegerMin( + issues, + `${STATIC_ANALYSIS_ROOT_PATH}.qlty.timeout`, + staticAnalysis.qlty?.timeout, + 1 + ); + validateBoolean( + issues, + `${STATIC_ANALYSIS_ROOT_PATH}.qlty.smells.enabled`, + staticAnalysis.qlty?.smells?.enabled + ); + validateIntegerMin( + issues, + `${STATIC_ANALYSIS_ROOT_PATH}.qlty.smells.timeout`, + staticAnalysis.qlty?.smells?.timeout, + 1 + ); + validateBoolean( + issues, + `${STATIC_ANALYSIS_ROOT_PATH}.qlty.smells.includeDuplication`, + staticAnalysis.qlty?.smells?.includeDuplication + ); + + validateIntegerRange( + issues, + `${STATIC_ANALYSIS_ROOT_PATH}.maxFindings`, + staticAnalysis.maxFindings, + 1, + 5000 + ); + validateEnum( + issues, + `${STATIC_ANALYSIS_ROOT_PATH}.minSeverity`, + staticAnalysis.minSeverity, + VALID_MIN_SEVERITY + ); + + if (issues.length > 0) { + throw new ConfigValidationError(issues); + } +} + +function validateBoolean(issues: ConfigValidationIssue[], path: string, value: unknown): void { + if (typeof value === 'boolean') { + return; + } + + issues.push({ + path, + code: 'invalid_type', + message: 'must be a boolean', + value, + }); +} + +function validateNonEmptyStringArray( + issues: ConfigValidationIssue[], + path: string, + value: unknown +): void { + if (!Array.isArray(value)) { + issues.push({ + path, + code: 'invalid_type', + message: 'must be an array of non-empty strings', + value, + }); + return; + } + + for (let i = 0; i < value.length; i += 1) { + const entry = value[i]; + if (typeof entry !== 'string' || entry.trim().length === 0) { + issues.push({ + path: `${path}[${i}]`, + code: 'invalid_array_item', + message: 'must be a non-empty string', + value: entry, + }); + } + } +} + +function validateIntegerMin( + issues: ConfigValidationIssue[], + path: string, + value: unknown, + min: number +): void { + if (typeof value !== 'number' || !Number.isFinite(value)) { + issues.push({ + path, + code: 'invalid_type', + message: 'must be a finite number', + value, + }); + return; + } + + if (!Number.isInteger(value)) { + issues.push({ + path, + code: 'invalid_integer', + message: 'must be an integer', + value, + }); + return; + } + + if (value < min) { + issues.push({ + path, + code: 'invalid_range', + message: `must be greater than or equal to ${min}`, + value, + }); + } +} + +function validateIntegerRange( + issues: ConfigValidationIssue[], + path: string, + value: unknown, + min: number, + max: number +): void { + if (typeof value !== 'number' || !Number.isFinite(value)) { + issues.push({ + path, + code: 'invalid_type', + message: 'must be a finite number', + value, + }); + return; + } + + if (!Number.isInteger(value)) { + issues.push({ + path, + code: 'invalid_integer', + message: 'must be an integer', + value, + }); + return; + } + + if (value < min || value > max) { + issues.push({ + path, + code: 'invalid_range', + message: `must be between ${min} and ${max}`, + value, + }); + } +} + +function validateEnum( + issues: ConfigValidationIssue[], + path: string, + value: unknown, + allowedValues: T +): void { + if (typeof value === 'string' && allowedValues.includes(value)) { + return; + } + + issues.push({ + path, + code: 'invalid_enum', + message: `expected one of ${allowedValues.join('|')}`, + value, + }); +} diff --git a/src/config/config.interface.ts b/src/config/config.interface.ts index 23ce8b6..90ee96e 100644 --- a/src/config/config.interface.ts +++ b/src/config/config.interface.ts @@ -2,6 +2,7 @@ // Commit Evaluator AppConfig interface (matches architecture-doc-generator pattern) import { AnalysisDepthMode } from '../types/agent.types'; +import { StaticAnalysisConfig } from '../services/static-analysis/types'; export interface AppConfig { apiKeys: { @@ -52,4 +53,5 @@ export interface AppConfig { excludePatterns?: string[]; // ['node_modules/**', 'dist/**'] chunkSize?: number; // Max chars per chunk (default: 1000) }; + staticAnalysis?: StaticAnalysisConfig; } diff --git a/src/config/default-config.ts b/src/config/default-config.ts index f6398e1..8aed8c1 100644 --- a/src/config/default-config.ts +++ b/src/config/default-config.ts @@ -3,6 +3,207 @@ import { AppConfig } from './config.interface'; +export const STATIC_ANALYSIS_DEFAULT_EXCLUDED_PATHS = [ + // --- Version control --- + '.git/**', + + // --- Dependencies & package managers --- + 'node_modules/**', + '.venv/**', + 'venv/**', + 'env/**', + 'vendor/**', + 'third_party/**', + 'third-party/**', + 'external/**', + 'bower_components/**', + + // --- Build outputs --- + 'dist/**', + 'build/**', + 'out/**', + 'output/**', + 'target/**', + 'bin/**', + 'obj/**', + '_build/**', + '.output/**', + + // --- Framework-specific outputs --- + '.next/**', + '.nuxt/**', + '.vercel/**', + '.serverless/**', + '.netlify/**', + + // --- Caches --- + '.cache/**', + '.turbo/**', + '__pycache__/**', + '.mypy_cache/**', + '.ruff_cache/**', + '.pytest_cache/**', + '.phpunit.cache/**', + '.gradle/**', + '.maven/**', + '.cargo/**', + '.npm/**', + '.yarn/**', + '.pnpm-store/**', + + // --- Test coverage --- + 'coverage/**', + '.nyc_output/**', + 'htmlcov/**', + + // --- Test files --- + '**/__tests__/**', + '**/test/**', + '**/tests/**', + '**/*.test.*', + '**/*.spec.*', + '**/*_test.*', + '**/*_spec.*', + + // --- Documentation (low-signal for static analyzers) --- + 'docs/**', + 'doc/**', + '**/*.md', + '**/*.markdown', + '**/*.mdx', + '**/*.rst', + '**/*.adoc', + '**/*.asciidoc', + + // --- IDE & editor configs --- + '.idea/**', + '.vscode/**', + '.vs/**', + '.eclipse/**', + '*.swp', + '*.swo', + '*~', + + // --- Generated code --- + '**/generated/**', + '**/auto-generated/**', + '**/autogenerated/**', + '**/*.generated.*', + '**/*.g.dart', + '**/*.freezed.dart', + '**/*.pb.go', + '**/*.pb.cc', + '**/*.pb.h', + '**/*.pb.js', + '**/*.pb.ts', + '**/*.grpc.go', + '**/swagger-gen/**', + '**/openapi-gen/**', + + // --- Minified & bundled files --- + '**/*.min.js', + '**/*.min.css', + '**/*.bundle.js', + '**/*.chunk.js', + '**/*.min.mjs', + + // --- Binary & media assets (not parseable by analyzers) --- + '**/*.png', + '**/*.jpg', + '**/*.jpeg', + '**/*.gif', + '**/*.ico', + '**/*.svg', + '**/*.webp', + '**/*.bmp', + '**/*.woff', + '**/*.woff2', + '**/*.ttf', + '**/*.eot', + '**/*.otf', + '**/*.mp3', + '**/*.mp4', + '**/*.wav', + '**/*.avi', + '**/*.mov', + '**/*.pdf', + '**/*.zip', + '**/*.tar', + '**/*.gz', + '**/*.jar', + '**/*.war', + '**/*.ear', + '**/*.dll', + '**/*.exe', + '**/*.so', + '**/*.dylib', + '**/*.class', + '**/*.pyc', + '**/*.pyo', + '**/*.o', + '**/*.a', + '**/*.lib', + '**/*.wasm', + + // --- CI/CD & infrastructure configs --- + '.github/**', + '.gitlab/**', + '.circleci/**', + '.terraform/**', + '**/.helmignore', + + // --- Lockfiles and generated dependency manifests across ecosystems --- + '**/package-lock.json', + '**/npm-shrinkwrap.json', + '**/yarn.lock', + '**/pnpm-lock.yaml', + '**/pnpm-lock.yml', + '**/bun.lock', + '**/bun.lockb', + '**/deno.lock', + '**/Pipfile.lock', + '**/poetry.lock', + '**/pdm.lock', + '**/uv.lock', + '**/pixi.lock', + '**/requirements.lock', + '**/conda-lock.yml', + '**/conda-lock.yaml', + '**/Gemfile.lock', + '**/composer.lock', + '**/Cargo.lock', + '**/go.sum', + '**/go.work.sum', + '**/Package.resolved', + '**/Podfile.lock', + '**/Cartfile.resolved', + '**/packages.lock.json', + '**/paket.lock', + '**/pubspec.lock', + '**/mix.lock', + '**/rebar.lock', + '**/deps-lock.json', + '**/gradle.lockfile', + '**/*.gradle.lockfile', + '**/maven-dependency-locks/**', + '**/conan.lock', + '**/Conan.lock', + '**/vcpkg-lock.json', + '**/spago.lock', + '**/stack.yaml.lock', + '**/cabal.project.freeze', + '**/renv.lock', + '**/packrat.lock', + '**/Manifest.toml', + '**/nimble.lock', + '**/.terraform.lock.hcl', + '**/Chart.lock', + '**/flake.lock', + '**/dub.selections.json', + '**/opam.locked', + '**/*.opam.locked', +] as const; + export const DEFAULT_CONFIG: AppConfig = { apiKeys: { anthropic: '', @@ -52,6 +253,28 @@ export const DEFAULT_CONFIG: AppConfig = { excludePatterns: ['node_modules/**', 'dist/**', '.git/**', 'coverage/**'], chunkSize: 1000, }, + staticAnalysis: { + enabled: true, + autoInstall: false, + autoInstallSystemDeps: false, + excludedPaths: [...STATIC_ANALYSIS_DEFAULT_EXCLUDED_PATHS], + semgrep: { + enabled: true, + rules: ['auto'], + timeout: 120000, + }, + qlty: { + enabled: true, + timeout: 60000, + smells: { + enabled: true, + timeout: 60000, + includeDuplication: true, + }, + }, + maxFindings: 500, + minSeverity: 'warning', + }, }; // For backwards compatibility diff --git a/src/mcp-server/__tests__/index.test.ts b/src/mcp-server/__tests__/index.test.ts new file mode 100644 index 0000000..3e635d4 --- /dev/null +++ b/src/mcp-server/__tests__/index.test.ts @@ -0,0 +1,356 @@ +/** + * Tests for MCP Server initialization (index.ts) + * + * Tests the MCP server setup, request handlers, and error handling. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// ============================================================================ +// Mock Setup - Must be before imports +// ============================================================================ + +// Store registered handlers so we can test them +const registeredHandlers: Map Promise> = new Map(); + +const mockServer = { + setRequestHandler: vi.fn((schema: unknown, handler: (request: unknown) => Promise) => { + registeredHandlers.set(schema, handler); + }), + connect: vi.fn().mockResolvedValue(undefined), +}; + +const mockStdioTransport = {}; + +vi.mock('@modelcontextprotocol/sdk/server/index.js', () => ({ + Server: class { + constructor() { + return mockServer; + } + }, +})); + +vi.mock('@modelcontextprotocol/sdk/server/stdio.js', () => ({ + StdioServerTransport: class { + constructor() { + return mockStdioTransport; + } + }, +})); + +// Mock tool registry and handlers +const mockGetAllTools = vi.fn(); +const mockGetToolHandler = vi.fn(); + +vi.mock('../tools/tool-registry', () => ({ + getAllTools: mockGetAllTools, +})); + +vi.mock('../tools/handlers', () => ({ + getToolHandler: mockGetToolHandler, +})); + +// Import after mocks +import { + CallToolRequestSchema, + InitializeRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; + +// ============================================================================ +// Test Setup +// ============================================================================ + +describe('MCP Server (index.ts)', () => { + beforeEach(() => { + vi.clearAllMocks(); + registeredHandlers.clear(); + + // Re-import the module to trigger handler registration + vi.resetModules(); + }); + + afterEach(() => { + vi.resetModules(); + }); + + // Helper to load the server module and get handlers + async function loadServerAndGetHandlers() { + // Import the module (this triggers handler registration) + await import('../index.js'); + + return { + initializeHandler: registeredHandlers.get(InitializeRequestSchema), + listToolsHandler: registeredHandlers.get(ListToolsRequestSchema), + callToolHandler: registeredHandlers.get(CallToolRequestSchema), + }; + } + + // ============================================================================ + // Server Initialization Tests + // ============================================================================ + + describe('Server Setup', () => { + it('should register all three request handlers', async () => { + // Act + await loadServerAndGetHandlers(); + + // Assert + expect(mockServer.setRequestHandler).toHaveBeenCalledTimes(3); + expect(registeredHandlers.has(InitializeRequestSchema)).toBe(true); + expect(registeredHandlers.has(ListToolsRequestSchema)).toBe(true); + expect(registeredHandlers.has(CallToolRequestSchema)).toBe(true); + }); + }); + + // ============================================================================ + // Initialize Handler Tests + // ============================================================================ + + describe('InitializeRequestSchema Handler', () => { + it('should return protocol version and server info', async () => { + // Arrange + const { initializeHandler } = await loadServerAndGetHandlers(); + + // Act + const result = await initializeHandler!({}); + + // Assert + expect(result).toEqual({ + protocolVersion: '2025-06-18', + capabilities: { + tools: {}, + }, + serverInfo: { + name: 'codewave-mcp-server', + version: '0.1.11', + }, + }); + }); + }); + + // ============================================================================ + // ListTools Handler Tests + // ============================================================================ + + describe('ListToolsRequestSchema Handler', () => { + it('should return all tools from registry', async () => { + // Arrange + const mockTools = [ + { name: 'evaluate_commit', description: 'Evaluate a commit' }, + { name: 'check_config', description: 'Check config' }, + ]; + mockGetAllTools.mockReturnValue(mockTools); + const { listToolsHandler } = await loadServerAndGetHandlers(); + + // Act + const result = await listToolsHandler!({}); + + // Assert + expect(mockGetAllTools).toHaveBeenCalled(); + expect(result).toEqual({ tools: mockTools }); + }); + + it('should return empty array when no tools registered', async () => { + // Arrange + mockGetAllTools.mockReturnValue([]); + const { listToolsHandler } = await loadServerAndGetHandlers(); + + // Act + const result = await listToolsHandler!({}); + + // Assert + expect(result).toEqual({ tools: [] }); + }); + }); + + // ============================================================================ + // CallTool Handler Tests + // ============================================================================ + + describe('CallToolRequestSchema Handler', () => { + it('should call the correct handler for a known tool', async () => { + // Arrange + const mockHandler = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: '{"success": true}' }], + }); + mockGetToolHandler.mockReturnValue(mockHandler); + const { callToolHandler } = await loadServerAndGetHandlers(); + + // Act + const result = await callToolHandler!({ + params: { + name: 'evaluate_commit', + arguments: { commit_hash: 'abc123' }, + }, + }); + + // Assert + expect(mockGetToolHandler).toHaveBeenCalledWith('evaluate_commit'); + expect(mockHandler).toHaveBeenCalledWith( + { commit_hash: 'abc123' }, + expect.objectContaining({ + projectPath: expect.any(String), + config: null, + logger: expect.any(Object), + }) + ); + expect(result).toEqual({ + content: [{ type: 'text', text: '{"success": true}' }], + isError: undefined, + }); + }); + + it('should return error for unknown tool', async () => { + // Arrange + mockGetToolHandler.mockReturnValue(undefined); + const { callToolHandler } = await loadServerAndGetHandlers(); + + // Act + const result = await callToolHandler!({ + params: { + name: 'unknown_tool', + arguments: {}, + }, + }); + + // Assert + expect(result).toEqual({ + content: [{ type: 'text', text: 'Unknown tool: unknown_tool' }], + isError: true, + }); + }); + + it('should handle handler errors gracefully', async () => { + // Arrange + const mockHandler = vi.fn().mockRejectedValue(new Error('Handler failed')); + mockGetToolHandler.mockReturnValue(mockHandler); + const { callToolHandler } = await loadServerAndGetHandlers(); + + // Act + const result = await callToolHandler!({ + params: { + name: 'evaluate_commit', + arguments: {}, + }, + }); + + // Assert + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error: Handler failed' }], + isError: true, + }); + }); + + it('should handle non-Error exceptions gracefully', async () => { + // Arrange + const mockHandler = vi.fn().mockRejectedValue('String error'); + mockGetToolHandler.mockReturnValue(mockHandler); + const { callToolHandler } = await loadServerAndGetHandlers(); + + // Act + const result = await callToolHandler!({ + params: { + name: 'evaluate_commit', + arguments: {}, + }, + }); + + // Assert + expect(result).toEqual({ + content: [{ type: 'text', text: 'Error: String error' }], + isError: true, + }); + }); + + it('should pass isError from handler result', async () => { + // Arrange + const mockHandler = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Validation error' }], + isError: true, + }); + mockGetToolHandler.mockReturnValue(mockHandler); + const { callToolHandler } = await loadServerAndGetHandlers(); + + // Act + const result = await callToolHandler!({ + params: { + name: 'evaluate_commit', + arguments: {}, + }, + }); + + // Assert + expect(result).toEqual({ + content: [{ type: 'text', text: 'Validation error' }], + isError: true, + }); + }); + + it('should use empty object when arguments not provided', async () => { + // Arrange + const mockHandler = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'OK' }], + }); + mockGetToolHandler.mockReturnValue(mockHandler); + const { callToolHandler } = await loadServerAndGetHandlers(); + + // Act + await callToolHandler!({ + params: { + name: 'check_config', + // No arguments provided + }, + }); + + // Assert + expect(mockHandler).toHaveBeenCalledWith( + {}, + expect.any(Object) + ); + }); + + it('should provide correct context to handler', async () => { + // Arrange + const mockHandler = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'OK' }], + }); + mockGetToolHandler.mockReturnValue(mockHandler); + const { callToolHandler } = await loadServerAndGetHandlers(); + + // Act + await callToolHandler!({ + params: { + name: 'evaluate_commit', + arguments: {}, + }, + }); + + // Assert + const context = mockHandler.mock.calls[0][1]; + expect(context).toHaveProperty('projectPath'); + expect(context).toHaveProperty('config', null); + expect(context).toHaveProperty('logger'); + expect(context.logger).toHaveProperty('info'); + expect(context.logger).toHaveProperty('error'); + expect(context.logger).toHaveProperty('warn'); + }); + }); + + // ============================================================================ + // startServer Tests + // ============================================================================ + + describe('startServer', () => { + it('should create StdioServerTransport and connect', async () => { + // Arrange + const { startServer } = await import('../index.js'); + + // Act + await startServer(); + + // Assert + expect(mockServer.connect).toHaveBeenCalledWith(mockStdioTransport); + }); + }); +}); diff --git a/src/mcp-server/index.ts b/src/mcp-server/index.ts new file mode 100644 index 0000000..3b8a842 --- /dev/null +++ b/src/mcp-server/index.ts @@ -0,0 +1,135 @@ +#!/usr/bin/env node + +/** + * MCP Server for CodeWave + * Exposes commit evaluation functionality to AI clients (Claude Desktop, etc.) + */ + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { + CallToolRequestSchema, + InitializeRequestSchema, + ListToolsRequestSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { getAllTools } from './tools/tool-registry'; +import { getToolHandler } from './tools/handlers'; +import { ToolContext } from './types'; + +/** + * Simple logger for MCP server + */ +const logger = { + info: (message: string) => console.error(`[INFO] ${message}`), + error: (message: string, error?: unknown) => + console.error(`[ERROR] ${message}`, error || ''), + warn: (message: string) => console.error(`[WARN] ${message}`), +}; + +/** + * Create MCP Server instance + */ +const server = new Server( + { + name: 'codewave-mcp-server', + version: '0.1.11', + }, + { + capabilities: { + tools: {}, + }, + }, +); + +/** + * Handle initialize request + */ +server.setRequestHandler(InitializeRequestSchema, async () => { + logger.info('Initialize request received'); + return { + protocolVersion: '2025-06-18', + capabilities: { + tools: {}, + }, + serverInfo: { + name: 'codewave-mcp-server', + version: '0.1.11', + }, + }; +}); + +/** + * List available tools + */ +server.setRequestHandler(ListToolsRequestSchema, async () => { + logger.info('ListTools request received'); + return { + tools: getAllTools(), + }; +}); + +/** + * Handle tool calls + * Routes to appropriate handler with context + */ +server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args = {} } = request.params; + const projectPath = process.cwd(); + + logger.info(`CallTool request received: ${name}`); + + try { + // Get the handler for this tool + const handler = getToolHandler(name); + if (!handler) { + return { + content: [ + { + type: 'text', + text: `Unknown tool: ${name}`, + }, + ], + isError: true, + }; + } + + // Create context for handler + const context: ToolContext = { + projectPath, + config: null, + logger, + }; + + // Call handler with context + const result = await handler(args, context); + + return { + content: result.content, + isError: result.isError, + }; + } catch (error) { + logger.error(`Tool ${name} failed`, error); + + return { + content: [ + { + type: 'text', + text: `Error: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + isError: true, + }; + } +}); + +/** + * Start the MCP server on stdio transport + */ +export async function startServer(): Promise { + const transport = new StdioServerTransport(); + logger.info('CodeWave MCP Server starting...'); + await server.connect(transport); + // Note: connect() never returns - server runs indefinitely +} + +// Note: Server is started via cli/mcp-server.ts entry point diff --git a/src/mcp-server/tools/handlers.ts b/src/mcp-server/tools/handlers.ts new file mode 100644 index 0000000..0d5e867 --- /dev/null +++ b/src/mcp-server/tools/handlers.ts @@ -0,0 +1,3277 @@ +/** + * Tool Handlers - All tool implementations + * Enhanced with comprehensive error handling and logging + */ + +import fs from 'fs'; +import path from 'path'; +import { spawnSync } from 'child_process'; +import { ContextualToolHandler, ToolResponse } from '../types'; +import { loadConfig, configExists } from '../../config/config-loader'; +import { DEFAULT_CONFIG } from '../../config/default-config'; +import { AppConfig } from '../../config/config.interface'; +import { ConfigValidationError } from '../../config/config-validator'; +import { createAgentRegistry, getEvaluationRoot, saveEvaluationReports, createEvaluationDirectory } from '../../../cli/utils/shared.utils'; +import { CommitEvaluationOrchestrator } from '../../orchestrator/commit-evaluation-orchestrator'; +import { getCommitDiff, extractCommitHash, extractFilesFromDiff, generateDiffHash } from '../../../cli/utils/git-utils'; +import { parseCommitStats } from '../../common/utils/commit-utils'; +import { AuthorStatsAggregatorService } from '../../services/author-stats-aggregator.service'; +import { OkrAgentService } from '../../services/okr-agent.service'; +import { OkrOrchestrator } from '../../orchestrator/okr-orchestrator'; +import { generateEnhancedHtmlReport } from '../../formatters/html-report-formatter-enhanced'; +import { generateConversationTranscript } from '../../formatters/conversation-transcript-formatter'; +import { AgentResult } from '../../agents/agent.interface'; +import pLimit from 'p-limit'; + +// ============================================================================ +// CONSTANTS: List Evaluations +// ============================================================================ + +const DEFAULT_EVALUATION_LIMIT = 20; +const MAX_EVALUATION_LIMIT = 100; +const DEFAULT_AUTHOR_STATS_LIMIT = 50; + +// ============================================================================ +// CONSTANTS +// ============================================================================ + +const VALID_PROVIDERS = ['openai', 'anthropic', 'groq', 'google', 'ollama', 'xai', 'lm-studio'] as const; +const VALID_DEPTHS = ['fast', 'normal', 'deep'] as const; +const VALID_FORMATS = ['full', 'summary', 'metrics_only'] as const; +const VALID_EXPORT_FORMATS = ['html', 'md', 'json'] as const; +const COMMIT_HASH_PATTERN = /^[a-fA-F0-9]{4,40}$|^HEAD(~\d+)?(\^+)?$/; + +// ============================================================================ +// CONSOLE SUPPRESSION FOR MCP +// ============================================================================ +// MCP uses stdio for JSON-RPC communication. Any console output to stdout +// interferes with the protocol and causes JSON parsing errors. +// This wrapper suppresses console output during long-running operations +// like evaluation and OKR generation. + +interface ConsoleMethods { + log: typeof console.log; + info: typeof console.info; + warn: typeof console.warn; + error: typeof console.error; +} + +/** + * Suppress console output during an async operation. + * This prevents emoji progress messages (πŸš€, πŸ“¦, βœ…) from interfering + * with MCP stdio JSON-RPC protocol. + * + * Note: This only affects MCP handlers. CLI uses a separate code path + * and is NOT affected by this suppression. + * + * @param fn - Async function to execute with suppressed console + * @returns The result of the async function + */ +export async function withSuppressedConsole(fn: () => Promise): Promise { + // Save original console methods + const original: ConsoleMethods = { + log: console.log, + info: console.info, + warn: console.warn, + error: console.error, + }; + + // Replace with no-ops (suppress output to stdout) + // eslint-disable-next-line @typescript-eslint/no-empty-function + const noop = () => {}; + console.log = noop; + console.info = noop; + console.warn = noop; + // Keep console.error but redirect to stderr (doesn't interfere with MCP) + // No prefix needed - messages already contain [INFO], [WARN], or [ERROR] from logger + console.error = (...args: unknown[]) => { + process.stderr.write(`${args.map(String).join(' ')}\n`); + }; + + try { + return await fn(); + } finally { + // Always restore original console methods + console.log = original.log; + console.info = original.info; + console.warn = original.warn; + console.error = original.error; + } +} + +// ============================================================================ +// HELPER TYPES +// ============================================================================ + +export interface ValidationError { + field: string; + message: string; + value?: unknown; +} + +interface EvaluationTiming { + startTime: number; + configLoadedAt?: number; + diffFetchedAt?: number; + evaluationStartedAt?: number; + evaluationEndedAt?: number; + totalDurationMs?: number; +} + +// ============================================================================ +// HELPER FUNCTIONS: Error Response Builders +// ============================================================================ + +/** + * Create a standardized error response + */ +export function createErrorResponse( + error: string, + message: string, + details?: Record, +): ToolResponse { + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + error, + message, + timestamp: new Date().toISOString(), + ...details, + }, + null, + 2, + ), + }, + ], + isError: true, + }; +} + +/** + * Create a validation error response with multiple errors + */ +export function createValidationErrorResponse(errors: ValidationError[]): ToolResponse { + return createErrorResponse('Validation failed', 'One or more input parameters are invalid', { + validationErrors: errors, + hint: 'Please check the parameter values and try again.', + }); +} + +function createStaticAnalysisConfigValidationResponse( + configPath: string, + error: ConfigValidationError +): ToolResponse { + return createErrorResponse( + 'Configuration load failed', + 'Config file contains invalid staticAnalysis values.', + { + path: configPath, + configPath, + validationErrors: error.issues, + hint: 'Update staticAnalysis values in .codewave.config.json to match supported constraints.', + } + ); +} + +// ============================================================================ +// HELPER FUNCTIONS: Validation +// ============================================================================ + +/** + * Validate all input parameters + */ +export function validateInputs(args: { + commit_hash: string; + repo_path?: string; + depth?: string; + provider?: string; + api_key?: string; + model?: string; +}): ValidationError[] { + const errors: ValidationError[] = []; + + // Validate commit_hash (required) + if (!args.commit_hash) { + errors.push({ + field: 'commit_hash', + message: 'commit_hash is required', + }); + } else if (!COMMIT_HASH_PATTERN.test(args.commit_hash)) { + errors.push({ + field: 'commit_hash', + message: 'Invalid commit hash format. Expected a git SHA (4-40 hex chars) or HEAD reference.', + value: args.commit_hash, + }); + } + + // Validate depth (optional, but must be valid if provided) + if (args.depth && !VALID_DEPTHS.includes(args.depth as typeof VALID_DEPTHS[number])) { + errors.push({ + field: 'depth', + message: `Invalid depth value. Must be one of: ${VALID_DEPTHS.join(', ')}`, + value: args.depth, + }); + } + + // Validate provider (optional, but must be valid if provided) + if (args.provider && !VALID_PROVIDERS.includes(args.provider as typeof VALID_PROVIDERS[number])) { + errors.push({ + field: 'provider', + message: `Invalid provider. Must be one of: ${VALID_PROVIDERS.join(', ')}`, + value: args.provider, + }); + } + + // Validate inline config completeness (if any inline param is provided, all must be) + const hasProvider = !!args.provider; + const hasApiKey = !!args.api_key; + const hasModel = !!args.model; + const inlineParamCount = [hasProvider, hasApiKey, hasModel].filter(Boolean).length; + + if (inlineParamCount > 0 && inlineParamCount < 3) { + const missing: string[] = []; + if (!hasProvider) missing.push('provider'); + if (!hasApiKey) missing.push('api_key'); + if (!hasModel) missing.push('model'); + errors.push({ + field: 'inline_config', + message: `Incomplete inline config. If using inline parameters, all three are required: provider, api_key, model. Missing: ${missing.join(', ')}`, + }); + } + + // Validate api_key is not empty string + if (hasApiKey && args.api_key!.trim().length === 0) { + errors.push({ + field: 'api_key', + message: 'api_key cannot be empty', + }); + } + + // Validate model is not empty string + if (hasModel && args.model!.trim().length === 0) { + errors.push({ + field: 'model', + message: 'model cannot be empty', + }); + } + + return errors; +} + +/** + * Check if a path is a valid git repository + */ +export function isGitRepository(repoPath: string): { valid: boolean; error?: string } { + try { + const gitDir = path.join(repoPath, '.git'); + if (!fs.existsSync(repoPath)) { + return { valid: false, error: `Path does not exist: ${repoPath}` }; + } + if (!fs.statSync(repoPath).isDirectory()) { + return { valid: false, error: `Path is not a directory: ${repoPath}` }; + } + if (!fs.existsSync(gitDir)) { + return { valid: false, error: `Not a git repository (no .git folder): ${repoPath}` }; + } + return { valid: true }; + } catch (err) { + return { + valid: false, + error: `Failed to check git repository: ${err instanceof Error ? err.message : String(err)}`, + }; + } +} + +// ============================================================================ +// HELPER FUNCTIONS: Config Building +// ============================================================================ + +/** + * Build a config from inline parameters (when no config file exists) + */ +export function buildInlineConfig( + provider: string, + apiKey: string, + model: string, + depth: 'fast' | 'normal' | 'deep' = 'normal', +): AppConfig { + return { + apiKeys: { + ...DEFAULT_CONFIG.apiKeys, + [provider]: apiKey, + }, + llm: { + provider: provider as AppConfig['llm']['provider'], + model: model, + temperature: DEFAULT_CONFIG.llm.temperature, + maxTokens: DEFAULT_CONFIG.llm.maxTokens, + baseUrl: DEFAULT_CONFIG.llm.baseUrl, + }, + agents: { + ...DEFAULT_CONFIG.agents, + depthMode: depth, + }, + output: { ...DEFAULT_CONFIG.output }, + tracing: { ...DEFAULT_CONFIG.tracing }, + documentation: DEFAULT_CONFIG.documentation + ? { ...DEFAULT_CONFIG.documentation } + : undefined, + staticAnalysis: DEFAULT_CONFIG.staticAnalysis + ? { + ...DEFAULT_CONFIG.staticAnalysis, + semgrep: { ...DEFAULT_CONFIG.staticAnalysis.semgrep }, + qlty: { + ...DEFAULT_CONFIG.staticAnalysis.qlty, + smells: { ...DEFAULT_CONFIG.staticAnalysis.qlty.smells }, + }, + excludedPaths: [...DEFAULT_CONFIG.staticAnalysis.excludedPaths], + } + : undefined, + }; +} + +// ============================================================================ +// HELPER FUNCTIONS: Error Classification +// ============================================================================ + +/** + * Classify and format errors from the evaluation process + */ +export function classifyError(error: unknown): { + type: string; + message: string; + details?: Record; + suggestion?: string; +} { + const errorMessage = error instanceof Error ? error.message : String(error); + const errorString = errorMessage.toLowerCase(); + + // API Key errors + if (errorString.includes('api key') || errorString.includes('apikey') || errorString.includes('unauthorized') || errorString.includes('401')) { + return { + type: 'API_KEY_ERROR', + message: 'Invalid or missing API key', + details: { originalError: errorMessage }, + suggestion: 'Check that your API key is correct and has not expired.', + }; + } + + // Rate limit errors + if (errorString.includes('rate limit') || errorString.includes('429') || errorString.includes('too many requests')) { + return { + type: 'RATE_LIMIT_ERROR', + message: 'API rate limit exceeded', + details: { originalError: errorMessage }, + suggestion: 'Wait a few minutes before retrying, or consider using a different API key.', + }; + } + + // Network errors + if (errorString.includes('network') || errorString.includes('econnrefused') || errorString.includes('enotfound') || errorString.includes('timeout')) { + return { + type: 'NETWORK_ERROR', + message: 'Network connection failed', + details: { originalError: errorMessage }, + suggestion: 'Check your internet connection and try again.', + }; + } + + // Model not found + if (errorString.includes('model') && (errorString.includes('not found') || errorString.includes('does not exist'))) { + return { + type: 'MODEL_NOT_FOUND', + message: 'The specified model was not found', + details: { originalError: errorMessage }, + suggestion: 'Check that the model name is correct for your provider.', + }; + } + + // Git errors + if (errorString.includes('git') || errorString.includes('commit') || errorString.includes('repository')) { + return { + type: 'GIT_ERROR', + message: 'Git operation failed', + details: { originalError: errorMessage }, + suggestion: 'Ensure the commit hash exists and the repository is valid.', + }; + } + + // Token/context limit errors + if (errorString.includes('token') || errorString.includes('context length') || errorString.includes('too long')) { + return { + type: 'TOKEN_LIMIT_ERROR', + message: 'Input too large for the model', + details: { originalError: errorMessage }, + suggestion: 'The commit diff may be too large. Try evaluating a smaller commit.', + }; + } + + // Default: unknown error + return { + type: 'UNKNOWN_ERROR', + message: errorMessage, + details: error instanceof Error ? { stack: error.stack } : undefined, + suggestion: 'An unexpected error occurred. Check the error details for more information.', + }; +} + +// ============================================================================ +// HELPER FUNCTIONS: Evaluation Directory Lookup +// ============================================================================ + +type EvalDirResult = + | { found: true; dir: string } + | { found: false; error: 'not_found' } + | { found: false; error: 'ambiguous'; matches: string[] }; + +/** + * Find evaluation directory by commit hash with exact match priority. + * This prevents ambiguity when one directory name is a prefix of another. + * E.g., "1fac813" should match "1fac813" exactly, not report ambiguity with "1fac8137" + */ +export function findEvaluationDirectory(evalRoot: string, hash: string): EvalDirResult { + const dirs = fs.readdirSync(evalRoot).filter((d) => { + // Only include directories that look like commit hashes (not .okrs, index.json, etc.) + return fs.statSync(path.join(evalRoot, d)).isDirectory() && !d.startsWith('.'); + }); + const hashLower = hash.toLowerCase(); + + // Priority 1: Exact match + const exactMatch = dirs.find((dir) => dir.toLowerCase() === hashLower); + if (exactMatch) { + return { found: true, dir: exactMatch }; + } + + // Priority 2: Partial match (startsWith) + const matchingDirs = dirs.filter((dir) => dir.toLowerCase().startsWith(hashLower)); + + if (matchingDirs.length === 0) { + return { found: false, error: 'not_found' }; + } + + if (matchingDirs.length > 1) { + return { found: false, error: 'ambiguous', matches: matchingDirs.slice(0, 5) }; + } + + return { found: true, dir: matchingDirs[0] }; +} + +// ============================================================================ +// MAIN HANDLER +// ============================================================================ + +/** + * Handler: evaluate_commit + * Evaluates a git commit using CodeWave's multi-agent AI analysis + */ +export const handleEvaluateCommit: ContextualToolHandler = async ( + args, + context, +): Promise => { + // Initialize timing + const timing: EvaluationTiming = { + startTime: Date.now(), + }; + + // Extract parameters + const { commit_hash, repo_path, depth = 'normal', provider, api_key, model, save = false } = args as { + commit_hash: string; + repo_path?: string; + depth?: 'fast' | 'normal' | 'deep'; + provider?: string; + api_key?: string; + model?: string; + save?: boolean; + }; + + const projectPath = repo_path || context.projectPath; + const configFilePath = path.join(projectPath, '.codewave.config.json'); + + // Log start with all parameters (mask api_key for security) + context.logger.info('=== evaluate_commit START ==='); + context.logger.info(`Parameters: commit_hash=${commit_hash}, repo_path=${projectPath}, depth=${depth}`); + if (provider) { + context.logger.info(`Inline config: provider=${provider}, model=${model}, api_key=${api_key ? '***' + api_key.slice(-4) : 'not provided'}`); + } + + try { + // ======================================================================== + // STEP 1: Input Validation + // ======================================================================== + context.logger.info('Step 1: Validating inputs...'); + const validationErrors = validateInputs({ commit_hash, repo_path, depth, provider, api_key, model }); + + if (validationErrors.length > 0) { + context.logger.error(`Validation failed with ${validationErrors.length} error(s)`); + validationErrors.forEach((err) => context.logger.error(` - ${err.field}: ${err.message}`)); + return createValidationErrorResponse(validationErrors); + } + context.logger.info('Input validation passed'); + + // ======================================================================== + // STEP 2: Git Repository Validation + // ======================================================================== + context.logger.info('Step 2: Validating git repository...'); + const gitCheck = isGitRepository(projectPath); + + if (!gitCheck.valid) { + context.logger.error(`Git validation failed: ${gitCheck.error}`); + return createErrorResponse('Invalid repository', gitCheck.error || 'Not a valid git repository', { + repo_path: projectPath, + hint: 'Ensure the path points to a valid git repository with a .git folder.', + }); + } + context.logger.info(`Git repository validated: ${projectPath}`); + + // ======================================================================== + // STEP 3: Configuration Loading + // ======================================================================== + context.logger.info('Step 3: Loading configuration...'); + let config: AppConfig; + let configSource: 'inline' | 'file'; + + const hasInlineConfig = provider && api_key && model; + const hasConfigFile = configExists(configFilePath); + + if (hasInlineConfig) { + configSource = 'inline'; + context.logger.info(`Using INLINE config: provider=${provider}, model=${model}`); + config = buildInlineConfig(provider, api_key, model, depth as 'fast' | 'normal' | 'deep'); + } else if (hasConfigFile) { + configSource = 'file'; + context.logger.info(`Using CONFIG FILE: ${configFilePath}`); + + let loadedConfig: AppConfig | null; + try { + loadedConfig = loadConfig(configFilePath); + } catch (configError) { + if (configError instanceof ConfigValidationError) { + context.logger.error(`Config validation error: ${configError.message}`); + return createStaticAnalysisConfigValidationResponse(configFilePath, configError); + } + + context.logger.error(`Config parse/load error: ${configError}`); + return createErrorResponse( + 'Configuration load failed', + 'Failed to load .codewave.config.json. Please check the file format.', + { + configPath: configFilePath, + originalError: + configError instanceof Error ? configError.message : String(configError), + hint: 'Ensure the config file is valid JSON and follows the expected schema.', + } + ); + } + + if (!loadedConfig) { + context.logger.error('Failed to parse config file'); + return createErrorResponse('Configuration load failed', 'Failed to load .codewave.config.json. Please check the file format.', { + configPath: configFilePath, + hint: 'Ensure the config file is valid JSON and follows the expected schema.', + }); + } + config = loadedConfig; + config.agents.depthMode = depth as 'fast' | 'normal' | 'deep'; + context.logger.info(`Config loaded: provider=${config.llm.provider}, model=${config.llm.model}`); + } else { + context.logger.error('No configuration available'); + return createErrorResponse( + 'Configuration required', + 'No .codewave.config.json found and no inline config provided.', + { + projectPath, + hint: 'Either create a config file with "codewave config --init" or provide provider, api_key, and model parameters.', + }, + ); + } + + timing.configLoadedAt = Date.now(); + context.logger.info(`Config loaded in ${timing.configLoadedAt - timing.startTime}ms`); + + // ======================================================================== + // STEP 4: API Key Verification + // ======================================================================== + context.logger.info('Step 4: Verifying API key...'); + const activeProvider = config.llm.provider; + const activeApiKey = config.apiKeys[activeProvider]; + + if (!activeApiKey) { + context.logger.error(`No API key found for provider: ${activeProvider}`); + return createErrorResponse('API key not configured', `No API key found for provider: ${activeProvider}`, { + provider: activeProvider, + configSource, + hint: configSource === 'file' + ? `Add the API key to your .codewave.config.json under apiKeys.${activeProvider}` + : 'Provide a valid api_key parameter.', + }); + } + + // Warn if API key looks suspicious (too short, placeholder, etc.) + if (activeApiKey.length < 10) { + context.logger.warn(`API key for ${activeProvider} seems unusually short (${activeApiKey.length} chars)`); + } + if (activeApiKey.includes('your_') || activeApiKey.includes('xxx') || activeApiKey.includes('placeholder')) { + context.logger.warn(`API key for ${activeProvider} appears to be a placeholder`); + } + + context.logger.info(`API key verified for provider: ${activeProvider} (key ending in ...${activeApiKey.slice(-4)})`); + + // ======================================================================== + // STEP 5: Fetch Commit Diff + // ======================================================================== + context.logger.info(`Step 5: Fetching diff for commit: ${commit_hash}...`); + + let diff: string; + try { + diff = getCommitDiff(commit_hash, projectPath); + } catch (gitError) { + context.logger.error(`Git diff fetch failed: ${gitError}`); + return createErrorResponse('Git operation failed', `Failed to get diff for commit: ${commit_hash}`, { + commit_hash, + repo_path: projectPath, + originalError: gitError instanceof Error ? gitError.message : String(gitError), + hint: 'Ensure the commit hash exists in the repository. Use "git log" to verify.', + }); + } + + if (!diff || diff.trim().length === 0) { + context.logger.warn(`Empty diff returned for commit: ${commit_hash}`); + return createErrorResponse('No diff found', `Commit ${commit_hash} has no changes or does not exist.`, { + commit_hash, + hint: 'This might be an empty commit or a merge commit. Try a different commit hash.', + }); + } + + timing.diffFetchedAt = Date.now(); + const diffLines = diff.split('\n').length; + const diffSize = diff.length; + const filesChanged = extractFilesFromDiff(diff); + context.logger.info(`Diff fetched in ${timing.diffFetchedAt - (timing.configLoadedAt || timing.startTime)}ms`); + context.logger.info(`Diff stats: ${diffLines} lines, ${diffSize} bytes`); + + // Warn if diff is very large + if (diffSize > 100000) { + context.logger.warn(`Large diff detected (${diffSize} bytes). Evaluation may take longer or hit token limits.`); + } + + // ======================================================================== + // STEP 6: Resolve Commit Hash + // ======================================================================== + context.logger.info('Step 6: Resolving commit hash...'); + let resolvedHash = extractCommitHash(diff); + if (!resolvedHash) { + resolvedHash = generateDiffHash(diff); + context.logger.info(`Generated hash from diff: ${resolvedHash}`); + } else { + context.logger.info(`Extracted commit hash: ${resolvedHash}`); + } + + // ======================================================================== + // STEP 7: Create Agent Registry & Orchestrator + // ======================================================================== + context.logger.info('Step 7: Creating agent registry and orchestrator...'); + context.logger.info(`Enabled agents (${config.agents.enabled.length}): ${config.agents.enabled.join(', ')}`); + + let agentRegistry; + let orchestrator; + try { + agentRegistry = createAgentRegistry(config); + orchestrator = new CommitEvaluationOrchestrator(agentRegistry, config); + context.logger.info('Orchestrator created successfully'); + } catch (registryError) { + context.logger.error(`Failed to create agent registry: ${registryError}`); + return createErrorResponse('Agent initialization failed', 'Failed to initialize the evaluation agents.', { + originalError: registryError instanceof Error ? registryError.message : String(registryError), + hint: 'This might be a configuration issue. Check your agent settings.', + }); + } + + // ======================================================================== + // STEP 8: Run Evaluation + // ======================================================================== + context.logger.info('Step 8: Starting evaluation...'); + context.logger.info(`Evaluation settings: depth=${depth}, provider=${activeProvider}, model=${config.llm.model}`); + + timing.evaluationStartedAt = Date.now(); + + const evalContext = { + commitDiff: diff, + filesChanged, + commitHash: resolvedHash, + repoPath: projectPath, + config, + }; + + let evaluationResult; + try { + // Suppress console output during evaluation to prevent emoji progress + // messages from interfering with MCP stdio JSON-RPC protocol + evaluationResult = await withSuppressedConsole(() => + orchestrator.evaluateCommit(evalContext, { + streaming: false, + threadId: `mcp-eval-${Date.now()}`, + outputMode: 'mcp', + }) + ); + } catch (evalError) { + timing.evaluationEndedAt = Date.now(); + const evalDuration = timing.evaluationEndedAt - timing.evaluationStartedAt; + context.logger.error(`Evaluation failed after ${evalDuration}ms: ${evalError}`); + + const classified = classifyError(evalError); + return createErrorResponse(classified.type, classified.message, { + ...classified.details, + commit_hash, + provider: activeProvider, + model: config.llm.model, + evaluationDurationMs: evalDuration, + suggestion: classified.suggestion, + }); + } + + timing.evaluationEndedAt = Date.now(); + const evalDuration = timing.evaluationEndedAt - timing.evaluationStartedAt; + context.logger.info(`Evaluation completed in ${evalDuration}ms`); + + // ======================================================================== + // STEP 9: Process Results + // ======================================================================== + context.logger.info('Step 9: Processing results...'); + const results = evaluationResult.agentResults || []; + context.logger.info(`Received ${results.length} agent results`); + + // Check for empty results - likely indicates API/model error that wasn't thrown + if (results.length === 0) { + context.logger.error('No agent results returned - evaluation failed silently'); + return createErrorResponse( + 'Evaluation returned no results', + 'The evaluation completed but returned no agent results. This usually indicates an invalid API key or model.', + { + commit_hash, + provider: activeProvider, + model: config.llm.model, + configSource, + evaluationDurationMs: evalDuration, + hint: 'Verify your API key is valid and the model name is correct for your provider.', + suggestion: 'Try testing your API key directly with the provider to confirm it works.', + }, + ); + } + + // Extract metrics + const metrics: Record = {}; + let metricsCount = 0; + for (const result of results) { + if (result.metrics) { + for (const [key, value] of Object.entries(result.metrics)) { + if (typeof value === 'number') { + if (metrics[key] !== undefined) { + metrics[key] = (metrics[key] + value) / 2; + } else { + metrics[key] = value; + } + metricsCount++; + } + } + } + } + context.logger.info(`Extracted ${Object.keys(metrics).length} unique metrics from ${metricsCount} values`); + + // ======================================================================== + // STEP 10: Build Response + // ======================================================================== + timing.totalDurationMs = Date.now() - timing.startTime; + context.logger.info('Step 10: Building response...'); + + const response: Record = { + success: true, + commit_hash: resolvedHash, + depth, + provider: `${activeProvider} (${config.llm.model})`, + configSource, + metrics, + agentCount: results.length, + timing: { + totalDurationMs: timing.totalDurationMs, + evaluationDurationMs: evalDuration, + }, + developerOverview: evaluationResult.developerOverview, + agentSummaries: results.map((r: Record) => ({ + agent: r.agentName || r.agentRole, + summary: r.summary, + concerns: r.concerns || [], + })), + }; + + if (evaluationResult.staticAnalysis?.summary) { + response.staticAnalysisSummary = { + available: evaluationResult.staticAnalysis.summary.available, + counts: evaluationResult.staticAnalysis.summary.counts, + timing: evaluationResult.staticAnalysis.summary.timing, + truncated: evaluationResult.staticAnalysis.summary.truncated, + totalBeforeCap: evaluationResult.staticAnalysis.summary.totalBeforeCap, + warnings: evaluationResult.staticAnalysis.summary.warnings, + }; + } + + // ======================================================================== + // STEP 11: Optional Save to Disk + // ======================================================================== + if (save) { + context.logger.info('Step 11: Saving evaluation to disk...'); + try { + // Get commit metadata using git + let commitAuthor: string | undefined; + let commitMessage: string | undefined; + let commitDate: string | undefined; + + const showResult = spawnSync( + 'git', + ['show', '--no-patch', '--format=%an|||%s|||%aI', resolvedHash], + { cwd: projectPath, encoding: 'utf-8' } + ); + + if (showResult.status === 0 && showResult.stdout) { + const [author, message, date] = showResult.stdout.trim().split('|||'); + commitAuthor = author; + commitMessage = message; + commitDate = date; + } + + // Create evaluation directory + const outputDir = await createEvaluationDirectory(resolvedHash, projectPath); + + // Calculate commit statistics from diff + const commitStats = parseCommitStats(diff); + + // Prepare metadata + const metadata = { + timestamp: new Date().toISOString(), + commitHash: resolvedHash, + commitAuthor, + commitMessage, + commitDate, + source: 'commit' as const, + commitStats, + }; + + // Save all reports + await saveEvaluationReports({ + agentResults: results, + outputDir, + metadata, + diff, + developerOverview: evaluationResult.developerOverview, + staticAnalysis: evaluationResult.staticAnalysis, + }); + + response.savedTo = `.evaluated-commits/${resolvedHash}`; + context.logger.info(`Evaluation saved to: ${outputDir}`); + } catch (saveError) { + context.logger.warn(`Failed to save evaluation: ${saveError}`); + response.saveWarning = 'Evaluation completed but failed to save to disk.'; + } + } + + context.logger.info('=== evaluate_commit COMPLETE ==='); + context.logger.info(`Total duration: ${timing.totalDurationMs}ms | Agents: ${results.length} | Metrics: ${Object.keys(metrics).length}`); + + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2), + }, + ], + }; + } catch (error) { + // Catch-all for any unexpected errors + const totalDuration = Date.now() - timing.startTime; + context.logger.error('=== evaluate_commit FAILED (unexpected error) ==='); + context.logger.error(`Error after ${totalDuration}ms: ${error}`); + if (error instanceof Error && error.stack) { + context.logger.error(`Stack trace: ${error.stack}`); + } + + const classified = classifyError(error); + return createErrorResponse(classified.type, classified.message, { + ...classified.details, + commit_hash, + repo_path: projectPath, + durationMs: totalDuration, + suggestion: classified.suggestion, + }); + } +}; + +// ============================================================================ +// BATCH OPERATIONS +// ============================================================================ + +const MAX_PARALLEL_EXECUTIONS = 10; +const BATCH_COMMIT_PATTERN = /^last:(\d+)$/i; + +/** + * Handler: batch_evaluate + * Evaluate multiple commits in a single call + */ +export const handleBatchEvaluate: ContextualToolHandler = async ( + args, + context, +): Promise => { + const startTime = Date.now(); + context.logger.info('=== batch_evaluate START ==='); + + // Extract parameters + const { commits, repo_path, depth = 'normal', provider, api_key, model, save = true } = args as { + commits: string; + repo_path?: string; + depth?: 'fast' | 'normal' | 'deep'; + provider?: string; + api_key?: string; + model?: string; + save?: boolean; + }; + + const projectPath = repo_path || context.projectPath; + + context.logger.info(`Parameters: commits=${commits}, depth=${depth}, save=${save}`); + + try { + // ======================================================================== + // STEP 1: Input Validation + // ======================================================================== + context.logger.info('Step 1: Validating inputs...'); + + if (!commits || commits.trim().length === 0) { + return createErrorResponse( + 'Validation failed', + 'commits parameter is required', + { hint: 'Use "last:N" for last N commits, or comma-separated hashes like "abc123,def456"' }, + ); + } + + // Validate inline config if partial + const hasProvider = !!provider; + const hasApiKey = !!api_key; + const hasModel = !!model; + const inlineParamCount = [hasProvider, hasApiKey, hasModel].filter(Boolean).length; + + if (inlineParamCount > 0 && inlineParamCount < 3) { + const missing: string[] = []; + if (!hasProvider) missing.push('provider'); + if (!hasApiKey) missing.push('api_key'); + if (!hasModel) missing.push('model'); + return createErrorResponse( + 'Incomplete inline config', + `If using inline parameters, all three are required. Missing: ${missing.join(', ')}`, + { hint: 'Either provide all three (provider, api_key, model) or use a config file.' }, + ); + } + + // Validate git repository + const gitCheck = isGitRepository(projectPath); + if (!gitCheck.valid) { + return createErrorResponse('Invalid repository', gitCheck.error || 'Not a valid git repository', { + repo_path: projectPath, + }); + } + + // ======================================================================== + // STEP 2: Parse Commits + // ======================================================================== + context.logger.info('Step 2: Parsing commits...'); + + let commitHashes: string[] = []; + const batchMatch = commits.trim().match(BATCH_COMMIT_PATTERN); + + if (batchMatch) { + // Handle "last:N" syntax + const n = parseInt(batchMatch[1], 10); + + if (n < 1) { + return createErrorResponse( + 'Invalid batch size', + 'N must be at least 1 in "last:N"', + { value: commits, hint: 'Use "last:1" or higher.' }, + ); + } + + // Get last N commit hashes using git log + context.logger.info(`Fetching last ${n} commits...`); + const gitLogResult = spawnSync( + 'git', + ['log', `-${n}`, '--format=%H'], + { cwd: projectPath, encoding: 'utf-8' }, + ); + + if (gitLogResult.status !== 0) { + return createErrorResponse( + 'Git operation failed', + 'Failed to get commit history', + { originalError: gitLogResult.stderr, hint: 'Ensure the repository has commits.' }, + ); + } + + commitHashes = gitLogResult.stdout + .trim() + .split('\n') + .filter((h) => h.length > 0); + + context.logger.info(`Got ${commitHashes.length} commits from git log`); + } else { + // Handle comma-separated hashes + commitHashes = commits + .split(',') + .map((h) => h.trim()) + .filter((h) => h.length > 0); + + context.logger.info(`Parsed ${commitHashes.length} comma-separated hashes`); + } + + // Validate we have commits + if (commitHashes.length === 0) { + return createErrorResponse( + 'No commits specified', + 'Could not parse any commits from the input', + { value: commits, hint: 'Use "last:N" or comma-separated hashes.' }, + ); + } + + // Validate each hash format + for (const hash of commitHashes) { + if (!COMMIT_HASH_PATTERN.test(hash)) { + return createErrorResponse( + 'Invalid commit hash', + `Invalid hash format: ${hash}`, + { hint: 'Expected git SHA (4-40 hex chars) or HEAD reference.' }, + ); + } + } + + context.logger.info(`Will evaluate ${commitHashes.length} commits: ${commitHashes.map((h) => h.substring(0, 7)).join(', ')}`); + + // ======================================================================== + // STEP 3: Evaluate Each Commit + // ======================================================================== + context.logger.info('Step 3: Starting batch evaluation...'); + + interface CommitResult { + commit_hash: string; + short_hash: string; + status: 'success' | 'failed'; + author?: string; + message?: string; + metrics?: Record; + savedTo?: string; + error?: string; + } + + const results: CommitResult[] = []; + let successful = 0; + let failed = 0; + + // Create evaluation tasks with controlled parallelism (max 10 concurrent) + context.logger.info(`Starting parallel evaluation of ${commitHashes.length} commits (max ${MAX_PARALLEL_EXECUTIONS} concurrent)...`); + + const limit = pLimit(MAX_PARALLEL_EXECUTIONS); + + const evaluationTasks = commitHashes.map((hash, i) => limit(async (): Promise => { + context.logger.info(`[${i + 1}/${commitHashes.length}] Evaluating ${hash.substring(0, 7)}...`); + + try { + // Call the existing evaluate_commit handler + const evalResult = await handleEvaluateCommit( + { + commit_hash: hash, + repo_path: projectPath, + depth, + provider, + api_key, + model, + save, + }, + context, + ); + + // Parse the result + const resultText = evalResult.content[0]?.type === 'text' ? evalResult.content[0].text : '{}'; + const resultData = JSON.parse(resultText || '{}') as Record; + + if (evalResult.isError || resultData.error) { + return { + commit_hash: hash, + short_hash: hash.substring(0, 7), + status: 'failed', + error: (resultData.message as string) || (resultData.error as string) || 'Unknown error', + }; + } else { + // Get commit metadata + const showResult = spawnSync( + 'git', + ['show', '--no-patch', '--format=%an|||%s', hash], + { cwd: projectPath, encoding: 'utf-8' }, + ); + + let author = 'Unknown'; + let message = ''; + if (showResult.status === 0 && showResult.stdout) { + const [a, m] = showResult.stdout.trim().split('|||'); + author = a || 'Unknown'; + message = m || ''; + } + + return { + commit_hash: (resultData.commit_hash as string) || hash, + short_hash: ((resultData.commit_hash as string) || hash).substring(0, 7), + status: 'success', + author, + message, + metrics: resultData.metrics as Record, + savedTo: resultData.savedTo as string | undefined, + }; + } + } catch (evalError) { + return { + commit_hash: hash, + short_hash: hash.substring(0, 7), + status: 'failed', + error: evalError instanceof Error ? evalError.message : String(evalError), + }; + } + })); + + // Execute all evaluations with concurrency limit and wait for all to complete + const settledResults = await Promise.allSettled(evaluationTasks); + + // Process results + for (const settled of settledResults) { + if (settled.status === 'fulfilled') { + const result = settled.value; + results.push(result); + if (result.status === 'success') { + successful++; + } else { + failed++; + } + } else { + // Promise rejected (unexpected error) + failed++; + results.push({ + commit_hash: 'unknown', + short_hash: 'unknown', + status: 'failed', + error: settled.reason instanceof Error ? settled.reason.message : String(settled.reason), + }); + } + } + + // ======================================================================== + // STEP 4: Calculate Summary + // ======================================================================== + context.logger.info('Step 4: Calculating summary...'); + + const successfulResults = results.filter((r) => r.status === 'success' && r.metrics); + const summary: Record = {}; + + if (successfulResults.length > 0) { + // Collect all metric keys + const metricKeys = new Set(); + for (const r of successfulResults) { + if (r.metrics) { + Object.keys(r.metrics).forEach((k) => metricKeys.add(k)); + } + } + + // Calculate averages + for (const key of metricKeys) { + const values = successfulResults + .map((r) => r.metrics?.[key]) + .filter((v): v is number => typeof v === 'number'); + + if (values.length > 0) { + summary[`average${key.charAt(0).toUpperCase() + key.slice(1)}`] = + Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 100) / 100; + } + } + } + + // ======================================================================== + // STEP 5: Build Response + // ======================================================================== + const totalDurationMs = Date.now() - startTime; + context.logger.info(`batch_evaluate completed in ${totalDurationMs}ms`); + context.logger.info(`Results: ${successful} successful, ${failed} failed`); + context.logger.info('=== batch_evaluate COMPLETE ==='); + + const response = { + success: true, + total: commitHashes.length, + successful, + failed, + results, + summary: Object.keys(summary).length > 0 ? summary : null, + timing: { + totalDurationMs, + averagePerCommitMs: Math.round(totalDurationMs / commitHashes.length), + }, + }; + + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2), + }, + ], + }; + } catch (error) { + const totalDuration = Date.now() - startTime; + context.logger.error(`batch_evaluate failed after ${totalDuration}ms: ${error}`); + + const classified = classifyError(error); + return createErrorResponse(classified.type, classified.message, { + ...classified.details, + commits, + durationMs: totalDuration, + suggestion: classified.suggestion, + }); + } +}; + +// ============================================================================ +// COMPARISON TOOLS +// ============================================================================ + +/** + * Metric direction - determines if higher or lower values are better + */ +const METRIC_DIRECTION: Record = { + codeQuality: 'higher', + quality: 'higher', + testCoverage: 'higher', + functionalImpact: 'higher', + complexity: 'lower', // Lower complexity is better + technicalDebt: 'lower', // Lower debt is better + codeComplexity: 'lower', // Lower complexity is better + technicalDebtHours: 'lower', // Lower debt hours is better + commitScore: 'higher', +}; + +/** + * Extract and aggregate metrics from all agents in an evaluation + * Metrics are stored inside each agent, so we need to collect and average them + */ +function extractAggregatedMetrics(data: Record): Record { + const agents = (data.agents as Array>) || []; + const metricsCollector: Record = {}; + + // Collect all metrics from all agents + for (const agent of agents) { + const agentMetrics = (agent.metrics as Record) || {}; + for (const [key, value] of Object.entries(agentMetrics)) { + if (typeof value === 'number') { + if (!metricsCollector[key]) metricsCollector[key] = []; + metricsCollector[key].push(value); + } + } + } + + // Average each metric + const result: Record = {}; + for (const [key, values] of Object.entries(metricsCollector)) { + result[key] = Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 100) / 100; + } + + return result; +} + +/** + * Handler: compare_commits + * Compare metrics between two commits to show improvement or regression + */ +export const handleCompareCommits: ContextualToolHandler = async ( + args, + context, +): Promise => { + const startTime = Date.now(); + context.logger.info('=== compare_commits START ==='); + + const { commit_a, commit_b, repo_path } = args as { + commit_a: string; + commit_b: string; + repo_path?: string; + }; + + const projectPath = repo_path || context.projectPath; + const evalRoot = getEvaluationRoot(projectPath); + + context.logger.info(`Comparing commits: ${commit_a} vs ${commit_b}`); + + try { + // ======================================================================== + // STEP 1: Input Validation + // ======================================================================== + context.logger.info('Step 1: Validating inputs...'); + + if (!commit_a || commit_a.trim().length === 0) { + return createErrorResponse( + 'Validation failed', + 'commit_a is required', + { hint: 'Provide the baseline commit hash.' }, + ); + } + + if (!commit_b || commit_b.trim().length === 0) { + return createErrorResponse( + 'Validation failed', + 'commit_b is required', + { hint: 'Provide the commit hash to compare against baseline.' }, + ); + } + + // Check for same commit + if (commit_a.toLowerCase() === commit_b.toLowerCase()) { + return createErrorResponse( + 'Invalid comparison', + 'Cannot compare a commit to itself', + { hint: 'Provide two different commit hashes.' }, + ); + } + + // ======================================================================== + // STEP 2: Check evaluation directory exists + // ======================================================================== + context.logger.info('Step 2: Checking evaluation directory...'); + + if (!fs.existsSync(evalRoot)) { + return createErrorResponse( + 'No evaluations found', + 'The .evaluated-commits directory does not exist.', + { + evaluationRoot: evalRoot, + hint: 'Run: codewave evaluate --save to create evaluations first.', + }, + ); + } + + // ======================================================================== + // STEP 3: Find evaluation directories for both commits + // ======================================================================== + context.logger.info(`Step 3: Finding evaluation directories...`); + + // Find commit_a with exact match priority + const resultA = findEvaluationDirectory(evalRoot, commit_a); + if (!resultA.found) { + if (resultA.error === 'not_found') { + return createErrorResponse( + 'Evaluation not found', + `No evaluation found for commit '${commit_a}'.`, + { + commit: commit_a, + hint: `Run: codewave evaluate ${commit_a} --save to evaluate this commit first.`, + }, + ); + } + // resultA.error === 'ambiguous' + return createErrorResponse( + 'Ambiguous commit hash', + `Multiple evaluations match '${commit_a}'.`, + { + commit: commit_a, + matchingCommits: resultA.matches, + hint: 'Provide a longer commit hash to uniquely identify the evaluation.', + }, + ); + } + + // Find commit_b with exact match priority + const resultB = findEvaluationDirectory(evalRoot, commit_b); + if (!resultB.found) { + if (resultB.error === 'not_found') { + return createErrorResponse( + 'Evaluation not found', + `No evaluation found for commit '${commit_b}'.`, + { + commit: commit_b, + hint: `Run: codewave evaluate ${commit_b} --save to evaluate this commit first.`, + }, + ); + } + // resultB.error === 'ambiguous' + return createErrorResponse( + 'Ambiguous commit hash', + `Multiple evaluations match '${commit_b}'.`, + { + commit: commit_b, + matchingCommits: resultB.matches, + hint: 'Provide a longer commit hash to uniquely identify the evaluation.', + }, + ); + } + + const dirA = resultA.dir; + const dirB = resultB.dir; + + // Check if same evaluation (after resolving) + if (dirA === dirB) { + return createErrorResponse( + 'Invalid comparison', + 'Both hashes resolve to the same commit.', + { resolvedHash: dirA, hint: 'Provide two different commits.' }, + ); + } + + // ======================================================================== + // STEP 4: Load results.json for both commits + // ======================================================================== + context.logger.info('Step 4: Loading evaluation results...'); + + const resultsPathA = path.join(evalRoot, dirA, 'results.json'); + const resultsPathB = path.join(evalRoot, dirB, 'results.json'); + + if (!fs.existsSync(resultsPathA)) { + return createErrorResponse( + 'Results file missing', + `Evaluation directory exists but results.json is missing for commit_a.`, + { commit: dirA, hint: `Re-run: codewave evaluate ${commit_a} --save` }, + ); + } + + if (!fs.existsSync(resultsPathB)) { + return createErrorResponse( + 'Results file missing', + `Evaluation directory exists but results.json is missing for commit_b.`, + { commit: dirB, hint: `Re-run: codewave evaluate ${commit_b} --save` }, + ); + } + + let dataA: Record; + let dataB: Record; + + try { + dataA = JSON.parse(fs.readFileSync(resultsPathA, 'utf-8')); + } catch (parseError) { + return createErrorResponse( + 'Results file corrupted', + 'Failed to parse results.json for commit_a.', + { commit: dirA, hint: `Re-run: codewave evaluate ${commit_a} --save` }, + ); + } + + try { + dataB = JSON.parse(fs.readFileSync(resultsPathB, 'utf-8')); + } catch (parseError) { + return createErrorResponse( + 'Results file corrupted', + 'Failed to parse results.json for commit_b.', + { commit: dirB, hint: `Re-run: codewave evaluate ${commit_b} --save` }, + ); + } + + // ======================================================================== + // STEP 5: Extract metadata and metrics + // ======================================================================== + context.logger.info('Step 5: Extracting metrics...'); + + const metadataA = dataA.metadata as Record || {}; + const metadataB = dataB.metadata as Record || {}; + // Extract and aggregate metrics from all agents (metrics are stored per-agent, not at top level) + const metricsA = extractAggregatedMetrics(dataA); + const metricsB = extractAggregatedMetrics(dataB); + + // Build commit info objects + const commitInfoA = { + hash: dirA, + shortHash: dirA.substring(0, 7), + author: (metadataA.commitAuthor as string) || 'Unknown', + message: (metadataA.commitMessage as string) || '', + date: (metadataA.commitDate as string) || '', + }; + + const commitInfoB = { + hash: dirB, + shortHash: dirB.substring(0, 7), + author: (metadataB.commitAuthor as string) || 'Unknown', + message: (metadataB.commitMessage as string) || '', + date: (metadataB.commitDate as string) || '', + }; + + // ======================================================================== + // STEP 6: Calculate comparison + // ======================================================================== + context.logger.info('Step 6: Calculating comparison...'); + + // Get all metric keys from both + const allMetricKeys = new Set([...Object.keys(metricsA), ...Object.keys(metricsB)]); + + const comparison: Record = {}; + + const improved: string[] = []; + const regressed: string[] = []; + const unchanged: string[] = []; + + for (const key of allMetricKeys) { + const before = metricsA[key] ?? null; + const after = metricsB[key] ?? null; + + if (before === null || after === null) { + comparison[key] = { + before, + after, + diff: null, + percentChange: null, + trend: 'unknown', + }; + continue; + } + + const diff = Math.round((after - before) * 100) / 100; + const percentChange = before !== 0 + ? Math.round(((after - before) / before) * 1000) / 10 + : null; + + // Determine trend based on metric direction + const direction = METRIC_DIRECTION[key] || 'higher'; + let trend: 'improved' | 'regressed' | 'unchanged'; + + if (Math.abs(diff) < 0.5) { + trend = 'unchanged'; + unchanged.push(key); + } else if (direction === 'higher') { + trend = diff > 0 ? 'improved' : 'regressed'; + (diff > 0 ? improved : regressed).push(key); + } else { + // Lower is better + trend = diff < 0 ? 'improved' : 'regressed'; + (diff < 0 ? improved : regressed).push(key); + } + + comparison[key] = { + before, + after, + diff, + percentChange, + trend, + }; + } + + // Determine overall trend + let overallTrend: 'improved' | 'regressed' | 'mixed' | 'unchanged'; + if (improved.length > 0 && regressed.length === 0) { + overallTrend = 'improved'; + } else if (regressed.length > 0 && improved.length === 0) { + overallTrend = 'regressed'; + } else if (improved.length > 0 && regressed.length > 0) { + overallTrend = 'mixed'; + } else { + overallTrend = 'unchanged'; + } + + // ======================================================================== + // STEP 7: Build response + // ======================================================================== + const durationMs = Date.now() - startTime; + context.logger.info(`compare_commits completed in ${durationMs}ms`); + context.logger.info('=== compare_commits COMPLETE ==='); + + const response = { + success: true, + commit_a: commitInfoA, + commit_b: commitInfoB, + comparison, + summary: { + improved, + regressed, + unchanged, + overallTrend, + }, + timing: { durationMs }, + }; + + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2), + }, + ], + }; + } catch (error) { + const durationMs = Date.now() - startTime; + context.logger.error(`compare_commits failed after ${durationMs}ms: ${error}`); + + return createErrorResponse( + 'Comparison failed', + error instanceof Error ? error.message : String(error), + { + commit_a, + commit_b, + hint: 'An unexpected error occurred while comparing commits.', + }, + ); + } +}; + +// ============================================================================ +// TEAM ANALYTICS TOOLS +// ============================================================================ + +const DEFAULT_TEAM_STATS_LIMIT = 10; + +/** + * Handler: get_team_stats + * Get aggregated code quality statistics for all authors in a repository + */ +export const handleGetTeamStats: ContextualToolHandler = async ( + args, + context, +): Promise => { + const startTime = Date.now(); + context.logger.info('=== get_team_stats START ==='); + + const { repo_path, since, limit } = args as { + repo_path?: string; + since?: string; + limit?: number; + }; + + const projectPath = repo_path || context.projectPath; + const evalRoot = getEvaluationRoot(projectPath); + const effectiveLimit = limit ?? DEFAULT_TEAM_STATS_LIMIT; + + context.logger.info(`Getting team stats from: ${evalRoot}`); + context.logger.info(`Filters: since=${since || 'none'}, limit=${effectiveLimit}`); + + // Validate limit range + if (limit !== undefined) { + if (limit < 1) { + return createErrorResponse( + 'Validation failed', + 'limit must be at least 1', + { value: limit, hint: 'Default is 10 if not specified.' }, + ); + } + if (limit > MAX_EVALUATION_LIMIT) { + return createErrorResponse( + 'Validation failed', + `limit cannot exceed ${MAX_EVALUATION_LIMIT}`, + { value: limit, hint: `Maximum allowed is ${MAX_EVALUATION_LIMIT} authors.` }, + ); + } + } + + try { + // ======================================================================== + // STEP 1: Check evaluation directory exists + // ======================================================================== + context.logger.info('Step 1: Checking evaluation directory...'); + + if (!fs.existsSync(evalRoot)) { + return createErrorResponse( + 'No evaluations found', + 'The .evaluated-commits directory does not exist.', + { + evaluationRoot: evalRoot, + hint: 'Run: codewave evaluate HEAD --save to create evaluations first.', + }, + ); + } + + // ======================================================================== + // STEP 2: Scan all evaluations and group by author + // ======================================================================== + context.logger.info('Step 2: Scanning evaluations...'); + + // Parse since date if provided + let sinceDate: Date | undefined; + if (since) { + sinceDate = new Date(since); + if (isNaN(sinceDate.getTime())) { + return createErrorResponse( + 'Invalid date format', + `Could not parse date: ${since}`, + { hint: 'Use ISO format: 2024-01-01' }, + ); + } + } + + // Group evaluations by author + const authorData: Map; + }>; + }> = new Map(); + + const dirs = fs.readdirSync(evalRoot).filter((d) => { + const fullPath = path.join(evalRoot, d); + return fs.statSync(fullPath).isDirectory() && !d.startsWith('.'); + }); + + let totalEvaluations = 0; + const allDates: Date[] = []; + + for (const dir of dirs) { + const resultsPath = path.join(evalRoot, dir, 'results.json'); + if (!fs.existsSync(resultsPath)) continue; + + try { + const content = fs.readFileSync(resultsPath, 'utf-8'); + const data = JSON.parse(content) as Record; + + const metadata = (data.metadata as Record) || {}; + const author = (metadata.commitAuthor as string) || 'Unknown'; + const commitDate = (metadata.commitDate as string) || (data.timestamp as string) || ''; + const commitMessage = (metadata.commitMessage as string) || ''; + + // Apply date filter + if (sinceDate && commitDate) { + const evalDate = new Date(commitDate); + if (!isNaN(evalDate.getTime()) && evalDate < sinceDate) { + continue; // Skip evaluations before the since date + } + } + + // Track dates + if (commitDate) { + const d = new Date(commitDate); + if (!isNaN(d.getTime())) allDates.push(d); + } + + // Extract aggregated metrics + const metrics = extractAggregatedMetrics(data); + + // Add to author data + if (!authorData.has(author)) { + authorData.set(author, { commits: [] }); + } + authorData.get(author)!.commits.push({ + hash: dir, + message: commitMessage, + date: commitDate, + metrics, + }); + + totalEvaluations++; + } catch { + context.logger.warn(`Skipping malformed results.json in ${dir}`); + } + } + + context.logger.info(`Found ${totalEvaluations} evaluations from ${authorData.size} authors`); + + // ======================================================================== + // STEP 3: Calculate stats for each author + // ======================================================================== + context.logger.info('Step 3: Calculating author stats...'); + + interface AuthorStats { + name: string; + commitCount: number; + averageMetrics: Record; + trend: 'improving' | 'declining' | 'stable' | 'insufficient_data'; + } + + const authorStats: AuthorStats[] = []; + + for (const [author, data] of authorData.entries()) { + const commits = data.commits; + const commitCount = commits.length; + + // Calculate average metrics + const metricsCollector: Record = {}; + for (const commit of commits) { + for (const [key, value] of Object.entries(commit.metrics)) { + if (typeof value === 'number') { + if (!metricsCollector[key]) metricsCollector[key] = []; + metricsCollector[key].push(value); + } + } + } + + const averageMetrics: Record = {}; + for (const [key, values] of Object.entries(metricsCollector)) { + averageMetrics[key] = Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 100) / 100; + } + + // Calculate trend (compare first half to second half) + let trend: 'improving' | 'declining' | 'stable' | 'insufficient_data' = 'insufficient_data'; + if (commitCount >= 4) { + // Sort by date + const sortedCommits = [...commits].sort((a, b) => + new Date(a.date).getTime() - new Date(b.date).getTime() + ); + + const half = Math.floor(sortedCommits.length / 2); + const olderCommits = sortedCommits.slice(0, half); + const recentCommits = sortedCommits.slice(half); + + // Calculate average codeQuality for each half + const olderQuality = olderCommits + .filter((c) => c.metrics.codeQuality !== undefined) + .map((c) => c.metrics.codeQuality); + const recentQuality = recentCommits + .filter((c) => c.metrics.codeQuality !== undefined) + .map((c) => c.metrics.codeQuality); + + if (olderQuality.length > 0 && recentQuality.length > 0) { + const olderAvg = olderQuality.reduce((a, b) => a + b, 0) / olderQuality.length; + const recentAvg = recentQuality.reduce((a, b) => a + b, 0) / recentQuality.length; + + if (recentAvg > olderAvg + 0.5) { + trend = 'improving'; + } else if (recentAvg < olderAvg - 0.5) { + trend = 'declining'; + } else { + trend = 'stable'; + } + } + } + + authorStats.push({ + name: author, + commitCount, + averageMetrics, + trend, + }); + } + + // ======================================================================== + // STEP 4: Sort and limit + // ======================================================================== + context.logger.info('Step 4: Sorting and limiting results...'); + + // Sort by commit count descending + authorStats.sort((a, b) => b.commitCount - a.commitCount); + + // Apply limit + const limitedStats = authorStats.slice(0, effectiveLimit); + + // ======================================================================== + // STEP 5: Calculate team averages + // ======================================================================== + context.logger.info('Step 5: Calculating team averages...'); + + const teamMetricsCollector: Record = {}; + for (const author of authorStats) { + for (const [key, value] of Object.entries(author.averageMetrics)) { + if (typeof value === 'number') { + if (!teamMetricsCollector[key]) teamMetricsCollector[key] = []; + teamMetricsCollector[key].push(value); + } + } + } + + const teamAverages: Record = {}; + for (const [key, values] of Object.entries(teamMetricsCollector)) { + teamAverages[key] = Math.round((values.reduce((a, b) => a + b, 0) / values.length) * 100) / 100; + } + + // ======================================================================== + // STEP 6: Calculate date range + // ======================================================================== + let dateRange: { earliest: string; latest: string } | null = null; + if (allDates.length > 0) { + allDates.sort((a, b) => a.getTime() - b.getTime()); + dateRange = { + earliest: allDates[0].toISOString().split('T')[0], + latest: allDates[allDates.length - 1].toISOString().split('T')[0], + }; + } + + // ======================================================================== + // STEP 7: Build response + // ======================================================================== + const durationMs = Date.now() - startTime; + context.logger.info(`get_team_stats completed in ${durationMs}ms`); + context.logger.info('=== get_team_stats COMPLETE ==='); + + const response = { + success: true, + totalAuthors: authorData.size, + totalEvaluations, + authors: limitedStats, + teamAverages, + dateRange, + filters: { + since: since || null, + limit: effectiveLimit, + }, + timing: { durationMs }, + }; + + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2), + }, + ], + }; + } catch (error) { + const durationMs = Date.now() - startTime; + context.logger.error(`get_team_stats failed after ${durationMs}ms: ${error}`); + + return createErrorResponse( + 'Team stats failed', + error instanceof Error ? error.message : String(error), + { + hint: 'An unexpected error occurred while aggregating team statistics.', + }, + ); + } +}; + +// ============================================================================ +// EXPORT TOOLS +// ============================================================================ + +/** + * Handler: export_report + * Export evaluation report to a file in HTML, Markdown, or JSON format + */ +export const handleExportReport: ContextualToolHandler = async ( + args, + context, +): Promise => { + const startTime = Date.now(); + context.logger.info('=== export_report START ==='); + + const { commit_hash, output_path, format = 'html', repo_path } = args as { + commit_hash: string; + output_path: string; + format?: 'html' | 'md' | 'json'; + repo_path?: string; + }; + + const projectPath = repo_path || context.projectPath; + const evalRoot = getEvaluationRoot(projectPath); + + context.logger.info(`Exporting report for commit: ${commit_hash}`); + context.logger.info(`Format: ${format}, output: ${output_path}`); + + try { + // ======================================================================== + // STEP 1: Validate inputs + // ======================================================================== + context.logger.info('Step 1: Validating inputs...'); + + if (!commit_hash || commit_hash.trim().length === 0) { + return createErrorResponse( + 'Validation failed', + 'commit_hash is required', + { hint: 'Provide a commit hash to export. Use list_evaluations to find available commits.' }, + ); + } + + if (!output_path || output_path.trim().length === 0) { + return createErrorResponse( + 'Validation failed', + 'output_path is required', + { hint: 'Provide a file path where the report will be saved.' }, + ); + } + + if (format && !VALID_EXPORT_FORMATS.includes(format as typeof VALID_EXPORT_FORMATS[number])) { + return createErrorResponse( + 'Validation failed', + `Invalid format value. Must be one of: ${VALID_EXPORT_FORMATS.join(', ')}`, + { value: format, hint: 'Use "html" (default), "md" (markdown), or "json".' }, + ); + } + + // Validate file extension matches format + const fileExt = path.extname(output_path).toLowerCase().slice(1); // Remove leading dot + const expectedExtensions: Record = { + html: ['html', 'htm'], + md: ['md', 'markdown'], + json: ['json'], + }; + const validExtensions = expectedExtensions[format] || []; + + if (fileExt && !validExtensions.includes(fileExt)) { + return createErrorResponse( + 'File extension mismatch', + `Output file extension (.${fileExt}) doesn't match format (${format}).`, + { + format, + extension: fileExt, + expectedExtensions: validExtensions, + hint: `Use one of: ${validExtensions.map(e => '.' + e).join(', ')} for ${format} format.`, + }, + ); + } + + // Validate output directory exists + const outputDir = path.dirname(output_path); + if (!fs.existsSync(outputDir)) { + return createErrorResponse( + 'Output directory not found', + `The directory "${outputDir}" does not exist.`, + { hint: 'Create the directory first or provide a valid path.' }, + ); + } + + // ======================================================================== + // STEP 2: Find evaluation directory + // ======================================================================== + context.logger.info('Step 2: Finding evaluation...'); + + if (!fs.existsSync(evalRoot)) { + return createErrorResponse( + 'No evaluations found', + 'The .evaluated-commits directory does not exist.', + { + evaluationRoot: evalRoot, + hint: 'Run: codewave evaluate HEAD --save to create evaluations first.', + }, + ); + } + + const result = findEvaluationDirectory(evalRoot, commit_hash); + + if (!result.found) { + if (result.error === 'ambiguous') { + return createErrorResponse( + 'Ambiguous commit hash', + `Multiple commits match "${commit_hash}".`, + { + matches: result.matches?.map((m: string) => m.substring(0, 7)), + hint: 'Provide more characters to uniquely identify the commit.', + }, + ); + } + return createErrorResponse( + 'Evaluation not found', + `No evaluation found for commit "${commit_hash}".`, + { + hint: 'Use list_evaluations to see available commits, or run: codewave evaluate --save', + }, + ); + } + + const targetDir = result.dir!; + const resultsPath = path.join(evalRoot, targetDir, 'results.json'); + + // ======================================================================== + // STEP 3: Load results.json + // ======================================================================== + context.logger.info('Step 3: Loading evaluation data...'); + + if (!fs.existsSync(resultsPath)) { + return createErrorResponse( + 'Results file missing', + 'Evaluation directory exists but results.json is missing.', + { + commit_hash, + directory: targetDir, + hint: 'The evaluation may be incomplete. Re-run: codewave evaluate ' + commit_hash + ' --save', + }, + ); + } + + let data: Record; + try { + const content = fs.readFileSync(resultsPath, 'utf-8'); + data = JSON.parse(content); + } catch (parseError) { + return createErrorResponse( + 'Results file corrupted', + 'Failed to parse results.json.', + { + commit_hash, + directory: targetDir, + originalError: parseError instanceof Error ? parseError.message : String(parseError), + hint: 'Re-run: codewave evaluate ' + commit_hash + ' --save', + }, + ); + } + + // Extract data from results.json + const metadata = (data.metadata as Record) || {}; + const agents = (data.agents as AgentResult[]) || []; + const developerOverview = (data.developerOverview as string) || ''; + + // ======================================================================== + // STEP 4: Generate report based on format + // ======================================================================== + context.logger.info(`Step 4: Generating ${format} report...`); + + let bytesWritten = 0; + const resolvedOutputPath = path.resolve(output_path); + + try { + switch (format) { + case 'html': { + generateEnhancedHtmlReport(agents, resolvedOutputPath, { + commitHash: (metadata.commitHash as string) || targetDir, + timestamp: (metadata.timestamp as string) || (data.timestamp as string) || new Date().toISOString(), + commitAuthor: metadata.commitAuthor as string, + commitMessage: metadata.commitMessage as string, + commitDate: metadata.commitDate as string, + developerOverview, + filesChanged: metadata.filesChanged as number, + insertions: metadata.insertions as number, + deletions: metadata.deletions as number, + }); + bytesWritten = fs.statSync(resolvedOutputPath).size; + break; + } + + case 'md': { + generateConversationTranscript(agents, resolvedOutputPath, { + commitHash: (metadata.commitHash as string) || targetDir, + timestamp: (metadata.timestamp as string) || (data.timestamp as string) || new Date().toISOString(), + }); + bytesWritten = fs.statSync(resolvedOutputPath).size; + break; + } + + case 'json': { + const jsonContent = JSON.stringify(data, null, 2); + fs.writeFileSync(resolvedOutputPath, jsonContent, 'utf-8'); + bytesWritten = Buffer.byteLength(jsonContent, 'utf-8'); + break; + } + } + } catch (writeError) { + return createErrorResponse( + 'Export failed', + 'Failed to write report to output path.', + { + output_path: resolvedOutputPath, + format, + originalError: writeError instanceof Error ? writeError.message : String(writeError), + hint: 'Check file permissions and disk space.', + }, + ); + } + + // ======================================================================== + // STEP 5: Return success + // ======================================================================== + const durationMs = Date.now() - startTime; + context.logger.info(`export_report completed in ${durationMs}ms`); + context.logger.info('=== export_report COMPLETE ==='); + + const response = { + success: true, + commit: { + hash: targetDir, + shortHash: targetDir.substring(0, 7), + author: (metadata.commitAuthor as string) || 'Unknown', + message: (metadata.commitMessage as string) || '', + }, + export: { + format, + outputPath: resolvedOutputPath, + bytesWritten, + bytesHuman: bytesWritten > 1024 + ? `${(bytesWritten / 1024).toFixed(1)} KB` + : `${bytesWritten} bytes`, + }, + timing: { durationMs }, + }; + + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2), + }, + ], + }; + } catch (error) { + const durationMs = Date.now() - startTime; + context.logger.error(`export_report failed after ${durationMs}ms: ${error}`); + + return createErrorResponse( + 'Export failed', + error instanceof Error ? error.message : String(error), + { + commit_hash, + output_path, + format, + hint: 'An unexpected error occurred during export.', + }, + ); + } +}; + +// ============================================================================ +// CONFIGURATION TOOLS +// ============================================================================ + +/** + * Handler: check_config + * Verifies CodeWave configuration exists and is valid + */ +export const handleCheckConfig: ContextualToolHandler = async ( + args, + context, +): Promise => { + const startTime = Date.now(); + context.logger.info('=== check_config START ==='); + + const { repo_path } = args as { repo_path?: string }; + const projectPath = repo_path || context.projectPath; + const configFilePath = path.join(projectPath, '.codewave.config.json'); + + context.logger.info(`Checking config at: ${configFilePath}`); + + try { + // Step 1: Check if config file exists + if (!configExists(configFilePath)) { + context.logger.info('Config file not found'); + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + config: { + exists: false, + valid: false, + path: configFilePath, + }, + hint: 'No .codewave.config.json found. Run: codewave config --init', + }, + null, + 2, + ), + }, + ], + }; + } + + // Step 2: Try to load and parse config + let config: AppConfig | null; + try { + config = loadConfig(configFilePath); + } catch (parseError) { + if (parseError instanceof ConfigValidationError) { + context.logger.error(`Config validation error: ${parseError}`); + return createStaticAnalysisConfigValidationResponse(configFilePath, parseError); + } + + context.logger.error(`Config parse error: ${parseError}`); + return createErrorResponse( + 'Invalid configuration', + 'Config file exists but has invalid JSON syntax.', + { + path: configFilePath, + originalError: parseError instanceof Error ? parseError.message : String(parseError), + hint: 'Check the config file for JSON syntax errors (missing commas, brackets, etc.)', + }, + ); + } + + if (!config) { + return createErrorResponse( + 'Configuration load failed', + 'Config file exists but could not be loaded.', + { + path: configFilePath, + hint: 'Ensure the config file is valid JSON and follows the expected schema.', + }, + ); + } + + // Step 3: Verify API key is configured for the active provider + const activeProvider = config.llm.provider; + const activeApiKey = config.apiKeys[activeProvider]; + const apiKeyLower = activeApiKey?.toLowerCase() || ''; + const apiKeyConfigured = !!(activeApiKey && activeApiKey.length > 0 && !apiKeyLower.includes('your_') && !apiKeyLower.includes('xxx') && !apiKeyLower.includes('placeholder')); + + // Step 4: Build response + const durationMs = Date.now() - startTime; + context.logger.info(`Config check completed in ${durationMs}ms`); + context.logger.info('=== check_config COMPLETE ==='); + + const response = { + success: true, + config: { + exists: true, + valid: true, + path: configFilePath, + provider: activeProvider, + model: config.llm.model, + enabledAgents: config.agents.enabled, + apiKeyConfigured, + depthMode: config.agents.depthMode || 'normal', + temperature: config.llm.temperature, + maxTokens: config.llm.maxTokens, + }, + timing: { durationMs }, + }; + + // Add warning if API key not configured + if (!apiKeyConfigured) { + (response as Record).warning = `No valid API key found for provider '${activeProvider}'. Add it to apiKeys.${activeProvider} in your config.`; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2), + }, + ], + }; + } catch (error) { + context.logger.error(`check_config failed: ${error}`); + return createErrorResponse( + 'Configuration check failed', + error instanceof Error ? error.message : String(error), + { + path: configFilePath, + hint: 'An unexpected error occurred while checking the configuration.', + }, + ); + } +}; + +// ============================================================================ +// REPORT & DATA RETRIEVAL TOOLS +// ============================================================================ + +/** + * Handler: list_evaluations + * Lists all commit evaluations from .evaluated-commits folder + */ +export const handleListEvaluations: ContextualToolHandler = async ( + args, + context, +): Promise => { + const startTime = Date.now(); + context.logger.info('=== list_evaluations START ==='); + + const { repo_path, author, since, limit } = args as { + repo_path?: string; + author?: string; + since?: string; + limit?: number; + }; + + const projectPath = repo_path || context.projectPath; + const evalRoot = getEvaluationRoot(projectPath); + + // Apply limit constraints + const requestedLimit = limit ?? DEFAULT_EVALUATION_LIMIT; + const effectiveLimit = Math.min(Math.max(1, requestedLimit), MAX_EVALUATION_LIMIT); + + context.logger.info(`Listing evaluations from: ${evalRoot}`); + context.logger.info(`Filters: author=${author || 'none'}, since=${since || 'none'}, limit=${effectiveLimit}`); + + try { + // Step 1: Check if evaluation directory exists + if (!fs.existsSync(evalRoot)) { + context.logger.info('Evaluation directory not found'); + return { + content: [ + { + type: 'text', + text: JSON.stringify( + { + success: true, + evaluationRoot: evalRoot, + total: 0, + returned: 0, + filters: { author, since, limit: effectiveLimit }, + evaluations: [], + hint: 'No evaluations found. Run: codewave evaluate HEAD', + }, + null, + 2, + ), + }, + ], + }; + } + + // Step 2: Try to read index.json first (fast path) + const indexJsonPath = path.join(evalRoot, 'index.json'); + let evaluations: Array<{ + commitHash: string; + shortHash: string; + author: string; + message: string; + date: string; + directory: string; + evaluatedAt: string; + metrics?: Record; + }> = []; + + if (fs.existsSync(indexJsonPath)) { + context.logger.info('Reading from index.json (fast path)'); + try { + const indexContent = fs.readFileSync(indexJsonPath, 'utf-8'); + const indexData = JSON.parse(indexContent) as Array<{ + directory: string; + commitHash: string; + commitAuthor: string; + commitMessage: string; + commitDate: string; + lastEvaluated: string; + metrics?: Record; + }>; + + evaluations = indexData.map((item) => ({ + commitHash: item.commitHash || item.directory, + shortHash: (item.commitHash || item.directory).substring(0, 7), + author: item.commitAuthor || 'Unknown', + message: item.commitMessage || '', + date: item.commitDate || '', + directory: item.directory, + evaluatedAt: item.lastEvaluated || '', + metrics: item.metrics, + })); + } catch (parseError) { + context.logger.warn(`Failed to parse index.json, falling back to directory scan: ${parseError}`); + } + } + + // Step 3: If no index.json or parsing failed, scan directories + if (evaluations.length === 0) { + context.logger.info('Scanning directories for results.json files'); + const dirs = fs.readdirSync(evalRoot); + + for (const dir of dirs) { + const resultsPath = path.join(evalRoot, dir, 'results.json'); + if (!fs.existsSync(resultsPath)) continue; + + try { + const content = fs.readFileSync(resultsPath, 'utf-8'); + const data = JSON.parse(content); + + evaluations.push({ + commitHash: data.metadata?.commitHash || dir, + shortHash: (data.metadata?.commitHash || dir).substring(0, 7), + author: data.metadata?.commitAuthor || 'Unknown', + message: data.metadata?.commitMessage || '', + date: data.metadata?.commitDate || data.timestamp || '', + directory: dir, + evaluatedAt: data.timestamp || '', + metrics: data.metrics, + }); + } catch { + // Skip malformed files + context.logger.warn(`Skipping malformed results.json in ${dir}`); + } + } + } + + const totalBeforeFilter = evaluations.length; + context.logger.info(`Found ${totalBeforeFilter} total evaluations`); + + // Step 4: Apply filters + let filtered = evaluations; + + // Filter by author (case-insensitive partial match) + if (author) { + const authorLower = author.toLowerCase(); + filtered = filtered.filter((e) => e.author.toLowerCase().includes(authorLower)); + context.logger.info(`After author filter: ${filtered.length} evaluations`); + } + + // Filter by date + if (since) { + const sinceDate = new Date(since); + if (!isNaN(sinceDate.getTime())) { + filtered = filtered.filter((e) => { + const evalDate = new Date(e.date || e.evaluatedAt); + return !isNaN(evalDate.getTime()) && evalDate >= sinceDate; + }); + context.logger.info(`After date filter: ${filtered.length} evaluations`); + } else { + context.logger.warn(`Invalid date format for 'since': ${since}`); + } + } + + // Step 5: Sort by commit date descending + filtered.sort((a, b) => { + const dateA = new Date(a.date || a.evaluatedAt); + const dateB = new Date(b.date || b.evaluatedAt); + return dateB.getTime() - dateA.getTime(); + }); + + // Step 6: Apply limit + const totalAfterFilter = filtered.length; + const limitedResults = filtered.slice(0, effectiveLimit); + + // Step 7: Build response + const durationMs = Date.now() - startTime; + context.logger.info(`list_evaluations completed in ${durationMs}ms`); + context.logger.info('=== list_evaluations COMPLETE ==='); + + const response = { + success: true, + evaluationRoot: evalRoot, + total: totalAfterFilter, + returned: limitedResults.length, + filters: { + author: author || null, + since: since || null, + limit: effectiveLimit, + }, + evaluations: limitedResults, + timing: { durationMs }, + }; + + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2), + }, + ], + }; + } catch (error) { + context.logger.error(`list_evaluations failed: ${error}`); + return createErrorResponse( + 'Failed to list evaluations', + error instanceof Error ? error.message : String(error), + { + evaluationRoot: evalRoot, + hint: 'An unexpected error occurred while listing evaluations.', + }, + ); + } +}; + +/** + * Handler: get_report + * Retrieves detailed evaluation report for a specific commit + */ +export const handleGetReport: ContextualToolHandler = async ( + args, + context, +): Promise => { + const startTime = Date.now(); + context.logger.info('=== get_report START ==='); + + const { commit_hash, repo_path, format = 'summary' } = args as { + commit_hash: string; + repo_path?: string; + format?: 'full' | 'summary' | 'metrics_only'; + }; + + const projectPath = repo_path || context.projectPath; + const evalRoot = getEvaluationRoot(projectPath); + + context.logger.info(`Getting report for commit: ${commit_hash}`); + context.logger.info(`Format: ${format}, evalRoot: ${evalRoot}`); + + // Validate commit_hash + if (!commit_hash || commit_hash.trim().length === 0) { + return createErrorResponse( + 'Validation failed', + 'commit_hash is required', + { hint: 'Provide a commit hash to retrieve the report for. Use list_evaluations to find available commits.' }, + ); + } + + // Validate format + if (format && !VALID_FORMATS.includes(format as typeof VALID_FORMATS[number])) { + return createErrorResponse( + 'Validation failed', + `Invalid format value. Must be one of: ${VALID_FORMATS.join(', ')}`, + { value: format, hint: 'Use "summary" (default), "full", or "metrics_only".' }, + ); + } + + try { + // Step 1: Check if evaluation directory exists + if (!fs.existsSync(evalRoot)) { + return createErrorResponse( + 'No evaluations found', + 'The .evaluated-commits directory does not exist.', + { + evaluationRoot: evalRoot, + hint: 'Run: codewave evaluate HEAD to create your first evaluation.', + }, + ); + } + + // Step 2: Find matching directory with exact match priority + const result = findEvaluationDirectory(evalRoot, commit_hash); + + if (!result.found) { + if (result.error === 'not_found') { + context.logger.info(`No evaluation found for commit: ${commit_hash}`); + return createErrorResponse( + 'Evaluation not found', + `No evaluation found for commit '${commit_hash}'.`, + { + commit_hash, + hint: 'Run: codewave evaluate ' + commit_hash + ' to evaluate this commit, or use list_evaluations to see available reports.', + }, + ); + } + // result.error === 'ambiguous' + context.logger.warn(`Ambiguous hash: ${commit_hash} matches multiple directories`); + return createErrorResponse( + 'Ambiguous commit hash', + `Multiple evaluations match '${commit_hash}'. Provide more characters.`, + { + commit_hash, + matchingCommits: result.matches, + hint: 'Use a longer commit hash to uniquely identify the evaluation.', + }, + ); + } + + const targetDir = result.dir; + const resultsPath = path.join(evalRoot, targetDir, 'results.json'); + + // Step 3: Read results.json + if (!fs.existsSync(resultsPath)) { + return createErrorResponse( + 'Results file missing', + `Evaluation directory exists but results.json is missing.`, + { + commit_hash, + directory: targetDir, + hint: 'The evaluation may be incomplete. Re-run: codewave evaluate ' + commit_hash, + }, + ); + } + + let data: Record; + try { + const content = fs.readFileSync(resultsPath, 'utf-8'); + data = JSON.parse(content); + } catch (parseError) { + return createErrorResponse( + 'Results file corrupted', + 'Failed to parse results.json.', + { + commit_hash, + directory: targetDir, + originalError: parseError instanceof Error ? parseError.message : String(parseError), + hint: 'Re-run: codewave evaluate ' + commit_hash, + }, + ); + } + + // Step 4: Extract data based on format + const metadata = data.metadata as Record || {}; + const metrics = data.metrics as Record || {}; + const agentResults = data.agentResults as Array> || []; + const developerOverview = data.developerOverview as string || ''; + + // Extract commit hash info (used in all format responses) + const fullHash = (metadata.commitHash as string) || targetDir; + const shortHash = fullHash.substring(0, 7); + + // Extract top concerns from agent results + const topConcerns: string[] = []; + for (const agent of agentResults) { + const concerns = agent.concerns as string[] || []; + topConcerns.push(...concerns.slice(0, 2)); + } + + const durationMs = Date.now() - startTime; + context.logger.info(`get_report completed in ${durationMs}ms`); + context.logger.info('=== get_report COMPLETE ==='); + + // Build response based on format + let response: Record; + + if (format === 'metrics_only') { + response = { + success: true, + commitHash: fullHash, + shortHash, + metrics, + timing: { durationMs }, + }; + } else if (format === 'summary') { + response = { + success: true, + commitHash: fullHash, + shortHash, + author: metadata.commitAuthor || 'Unknown', + message: metadata.commitMessage || '', + date: metadata.commitDate || '', + metrics, + developerOverview: developerOverview || null, + topConcerns: topConcerns.slice(0, 5), + agentCount: agentResults.length, + evaluatedAt: data.timestamp || '', + timing: { durationMs }, + }; + } else { + // format === 'full' + response = { + success: true, + commitHash: fullHash, + shortHash, + author: metadata.commitAuthor || 'Unknown', + message: metadata.commitMessage || '', + date: metadata.commitDate || '', + metrics, + developerOverview: developerOverview || null, + topConcerns, + agentCount: agentResults.length, + agentResults: agentResults.map((agent) => ({ + agentName: agent.agentName || agent.agentRole, + summary: agent.summary, + concerns: agent.concerns || [], + metrics: agent.metrics || {}, + })), + evaluatedAt: data.timestamp || '', + directory: targetDir, + timing: { durationMs }, + }; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2), + }, + ], + }; + } catch (error) { + context.logger.error(`get_report failed: ${error}`); + return createErrorResponse( + 'Failed to get report', + error instanceof Error ? error.message : String(error), + { + commit_hash, + hint: 'An unexpected error occurred while retrieving the report.', + }, + ); + } +}; + +// ============================================================================ +// ANALYTICS TOOLS +// ============================================================================ + +/** + * Handler: get_author_stats + * Get aggregated code quality statistics for a developer + */ +export const handleGetAuthorStats: ContextualToolHandler = async ( + args, + context, +): Promise => { + const startTime = Date.now(); + context.logger.info('=== get_author_stats START ==='); + + const { author, repo_path, since, limit } = args as { + author: string; + repo_path?: string; + since?: string; + limit?: number; + }; + + const projectPath = repo_path || context.projectPath; + const evalRoot = getEvaluationRoot(projectPath); + + // Apply limit + const effectiveLimit = limit ?? DEFAULT_AUTHOR_STATS_LIMIT; + + context.logger.info(`Getting stats for author: ${author}`); + context.logger.info(`evalRoot: ${evalRoot}, since: ${since || 'none'}, limit: ${effectiveLimit}`); + + // Validate author parameter + if (!author || author.trim().length === 0) { + return createErrorResponse( + 'Validation failed', + 'author is required', + { hint: 'Provide an author name. Use list_evaluations to see available authors.' }, + ); + } + + try { + // Step 1: Check if evaluation directory exists + if (!fs.existsSync(evalRoot)) { + return createErrorResponse( + 'No evaluations found', + 'The .evaluated-commits directory does not exist.', + { + evaluationRoot: evalRoot, + hint: 'Run: codewave evaluate HEAD to create your first evaluation.', + }, + ); + } + + // Step 2: Aggregate author stats + const sinceDate = since ? new Date(since) : undefined; + if (since && sinceDate && isNaN(sinceDate.getTime())) { + return createErrorResponse( + 'Invalid date format', + `Could not parse date: ${since}`, + { hint: 'Use ISO format: 2024-01-01' }, + ); + } + + context.logger.info('Aggregating author stats...'); + const authorData = await AuthorStatsAggregatorService.aggregateAuthorStats(evalRoot, { + targetAuthor: author, + sinceDate, + countLimit: effectiveLimit, + }); + + // Step 3: Check if author found + if (authorData.size === 0) { + context.logger.info(`No evaluations found for author: ${author}`); + return createErrorResponse( + 'Author not found', + `No evaluations found for author '${author}'.`, + { + author, + hint: 'Check spelling (partial match is supported) or use list_evaluations to see available authors.', + }, + ); + } + + // Step 4: Get the matched author name and evaluations + // Since we use partial match, get the actual author name from the map + const matchedAuthors = Array.from(authorData.keys()); + const matchedAuthor = matchedAuthors[0]; + const evaluations = authorData.get(matchedAuthor) || []; + + if (evaluations.length === 0) { + return createErrorResponse( + 'No evaluations found', + `Author '${matchedAuthor}' found but has no evaluations.`, + { hint: 'The author may have been filtered out by date. Try without the since parameter.' }, + ); + } + + context.logger.info(`Found ${evaluations.length} evaluations for ${matchedAuthor}`); + + // Step 5: Analyze author + const analysis = AuthorStatsAggregatorService.analyzeAuthor(evaluations); + + // Step 6: Calculate date range + const dates = evaluations + .map((e: Record) => new Date((e.metadata as Record)?.commitDate || (e.timestamp as string))) + .filter((d: Date) => !isNaN(d.getTime())) + .sort((a: Date, b: Date) => a.getTime() - b.getTime()); + + const dateRange = dates.length > 0 ? { + earliest: dates[0].toISOString().split('T')[0], + latest: dates[dates.length - 1].toISOString().split('T')[0], + } : null; + + // Step 7: Calculate trend (compare recent vs older commits) + let trend: 'improving' | 'declining' | 'stable' = 'stable'; + if (evaluations.length >= 4) { + const half = Math.floor(evaluations.length / 2); + const recentEvals = evaluations.slice(0, half); + const olderEvals = evaluations.slice(half); + + const recentStats = AuthorStatsAggregatorService.calculateAverageMetrics(recentEvals); + const olderStats = AuthorStatsAggregatorService.calculateAverageMetrics(olderEvals); + + const recentScore = recentStats.commitScore || recentStats.quality; + const olderScore = olderStats.commitScore || olderStats.quality; + + if (recentScore > olderScore + 0.5) { + trend = 'improving'; + } else if (recentScore < olderScore - 0.5) { + trend = 'declining'; + } + } + + // Step 8: Build response + const durationMs = Date.now() - startTime; + context.logger.info(`get_author_stats completed in ${durationMs}ms`); + context.logger.info('=== get_author_stats COMPLETE ==='); + + const response = { + success: true, + author: matchedAuthor, + commitsAnalyzed: evaluations.length, + dateRange, + stats: analysis.stats, + strengths: analysis.strengths, + weaknesses: analysis.weaknesses, + trend, + timing: { durationMs }, + }; + + // Add note if multiple authors matched + if (matchedAuthors.length > 1) { + (response as Record).note = `Multiple authors matched '${author}'. Showing results for '${matchedAuthor}'. Other matches: ${matchedAuthors.slice(1).join(', ')}`; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2), + }, + ], + }; + } catch (error) { + context.logger.error(`get_author_stats failed: ${error}`); + return createErrorResponse( + 'Failed to get author stats', + error instanceof Error ? error.message : String(error), + { + author, + hint: 'An unexpected error occurred while aggregating author statistics.', + }, + ); + } +}; + +// ============================================================================ +// OKR GENERATION TOOLS +// ============================================================================ + +/** + * Handler: generate_okr + * Generate personalized OKRs and action plan for a developer using AI analysis + */ +export const handleGenerateOkr: ContextualToolHandler = async ( + args, + context, +): Promise => { + const startTime = Date.now(); + context.logger.info('=== generate_okr START ==='); + + const { author, repo_path, since, save = true, provider, api_key, model } = args as { + author: string; + repo_path?: string; + since?: string; + save?: boolean; + provider?: string; + api_key?: string; + model?: string; + }; + + const projectPath = repo_path || context.projectPath; + const evalRoot = getEvaluationRoot(projectPath); + const configFilePath = path.join(projectPath, '.codewave.config.json'); + + context.logger.info(`Generating OKRs for author: ${author}`); + context.logger.info(`evalRoot: ${evalRoot}, save: ${save}`); + if (provider) { + context.logger.info(`Inline config: provider=${provider}, model=${model}`); + } + + // Validate author parameter + if (!author || author.trim().length === 0) { + return createErrorResponse( + 'Validation failed', + 'author is required', + { hint: 'Provide an author name. Use list_evaluations to see available authors.' }, + ); + } + + try { + // ======================================================================== + // STEP 1: Configuration Loading (reuse pattern from evaluate_commit) + // ======================================================================== + context.logger.info('Step 1: Loading configuration...'); + let config: AppConfig; + let configSource: 'inline' | 'file'; + + const hasInlineConfig = provider && api_key && model; + const hasConfigFile = configExists(configFilePath); + + // Validate inline config completeness + const hasProvider = !!provider; + const hasApiKey = !!api_key; + const hasModel = !!model; + const inlineParamCount = [hasProvider, hasApiKey, hasModel].filter(Boolean).length; + + if (inlineParamCount > 0 && inlineParamCount < 3) { + const missing: string[] = []; + if (!hasProvider) missing.push('provider'); + if (!hasApiKey) missing.push('api_key'); + if (!hasModel) missing.push('model'); + return createErrorResponse( + 'Incomplete inline config', + `If using inline parameters, all three are required: provider, api_key, model. Missing: ${missing.join(', ')}`, + { hint: 'Either provide all three inline parameters or use a .codewave.config.json file.' }, + ); + } + + if (hasInlineConfig) { + configSource = 'inline'; + context.logger.info(`Using INLINE config: provider=${provider}, model=${model}`); + config = buildInlineConfig(provider, api_key, model); + } else if (hasConfigFile) { + configSource = 'file'; + context.logger.info(`Using CONFIG FILE: ${configFilePath}`); + + let loadedConfig: AppConfig | null; + try { + loadedConfig = loadConfig(configFilePath); + } catch (configError) { + if (configError instanceof ConfigValidationError) { + context.logger.error(`Config validation error: ${configError.message}`); + return createStaticAnalysisConfigValidationResponse(configFilePath, configError); + } + + context.logger.error(`Config parse/load error: ${configError}`); + return createErrorResponse( + 'Configuration load failed', + 'Failed to load .codewave.config.json. Please check the file format.', + { + configPath: configFilePath, + originalError: + configError instanceof Error ? configError.message : String(configError), + hint: 'Ensure the config file is valid JSON and follows the expected schema.', + } + ); + } + + if (!loadedConfig) { + return createErrorResponse( + 'Configuration load failed', + 'Failed to load .codewave.config.json. Please check the file format.', + { + configPath: configFilePath, + hint: 'Ensure the config file is valid JSON and follows the expected schema.', + }, + ); + } + config = loadedConfig; + } else { + return createErrorResponse( + 'Configuration required', + 'No .codewave.config.json found and no inline config provided.', + { + projectPath, + hint: 'Either create a config file with "codewave config --init" or provide provider, api_key, and model parameters.', + }, + ); + } + + // ======================================================================== + // STEP 2: API Key Verification + // ======================================================================== + context.logger.info('Step 2: Verifying API key...'); + const activeProvider = config.llm.provider; + const activeApiKey = config.apiKeys[activeProvider]; + + if (!activeApiKey) { + return createErrorResponse( + 'API key not configured', + `No API key found for provider: ${activeProvider}`, + { + provider: activeProvider, + configSource, + hint: configSource === 'file' + ? `Add the API key to your .codewave.config.json under apiKeys.${activeProvider}` + : 'Provide a valid api_key parameter.', + }, + ); + } + context.logger.info(`API key verified for provider: ${activeProvider}`); + + // ======================================================================== + // STEP 3: Check evaluation directory exists + // ======================================================================== + if (!fs.existsSync(evalRoot)) { + return createErrorResponse( + 'No evaluations found', + 'The .evaluated-commits directory does not exist.', + { + evaluationRoot: evalRoot, + hint: 'Run: codewave evaluate HEAD to create evaluations before generating OKRs.', + }, + ); + } + + // ======================================================================== + // STEP 4: Aggregate author stats + // ======================================================================== + context.logger.info('Step 4: Aggregating author stats...'); + const sinceDate = since ? new Date(since) : undefined; + + const authorData = await AuthorStatsAggregatorService.aggregateAuthorStats(evalRoot, { + targetAuthor: author, + sinceDate, + }); + + if (authorData.size === 0) { + return createErrorResponse( + 'Author not found', + `No evaluations found for author '${author}'.`, + { + author, + hint: 'Cannot generate OKRs without commit history. Check spelling or evaluate some commits first.', + }, + ); + } + + const matchedAuthor = Array.from(authorData.keys())[0]; + const evaluations = authorData.get(matchedAuthor) || []; + + if (evaluations.length === 0) { + return createErrorResponse( + 'No evaluations found', + `Author '${matchedAuthor}' has no evaluations.`, + { hint: 'Evaluate some commits first before generating OKRs.' }, + ); + } + + context.logger.info(`Found ${evaluations.length} evaluations for ${matchedAuthor}`); + + // ======================================================================== + // STEP 5: Analyze author + // ======================================================================== + context.logger.info('Step 5: Analyzing author...'); + const analysis = AuthorStatsAggregatorService.analyzeAuthor(evaluations); + + // ======================================================================== + // STEP 6: Generate OKRs using OkrAgentService + // ======================================================================== + context.logger.info('Step 6: Generating OKRs (this may take 10-30 seconds)...'); + const okrStartTime = Date.now(); + + const agentService = new OkrAgentService(config); + // Suppress console output during OKR generation to prevent emoji progress + // messages from interfering with MCP stdio JSON-RPC protocol + const okrData = await withSuppressedConsole(() => + agentService.generateOkrsForAuthor( + matchedAuthor, + analysis.stats, + analysis.strengths, + analysis.weaknesses, + evaluations, + evalRoot, + ) + ); + + const okrDurationMs = Date.now() - okrStartTime; + context.logger.info(`OKR generation completed in ${okrDurationMs}ms`); + + // ======================================================================== + // STEP 7: Optionally save to disk + // ======================================================================== + let savedTo: string | null = null; + + if (save) { + context.logger.info('Step 7: Saving OKRs to disk...'); + try { + const orchestrator = new OkrOrchestrator(config); + const okrMap = new Map(); + okrMap.set(matchedAuthor, okrData); + await orchestrator.saveOkrs(evalRoot, okrMap); + + // Construct the save path + const sanitizedAuthor = matchedAuthor.replace(/[^a-z0-9]/gi, '_').toLowerCase(); + const now = new Date(); + const date = now.toISOString().split('T')[0]; + const time = now.toTimeString().split(' ')[0].replace(/:/g, '-'); + savedTo = `.evaluated-commits/.okrs/${sanitizedAuthor}/okr_${date}_${time}.json`; + + context.logger.info(`OKRs saved to: ${savedTo}`); + } catch (saveError) { + context.logger.warn(`Failed to save OKRs: ${saveError}`); + // Don't fail the request, just note it + savedTo = null; + } + } else { + context.logger.info('Step 7: Skipping save (save=false)'); + } + + // ======================================================================== + // STEP 8: Build response + // ======================================================================== + const totalDurationMs = Date.now() - startTime; + context.logger.info('Step 8: Building response...'); + context.logger.info(`generate_okr completed in ${totalDurationMs}ms`); + context.logger.info('=== generate_okr COMPLETE ==='); + + const response: Record = { + success: true, + author: matchedAuthor, + generatedAt: new Date().toISOString(), + commitsAnalyzed: evaluations.length, + strongPoints: okrData.strongPoints || [], + weakPoints: okrData.weakPoints || [], + knowledgeGaps: okrData.knowledgeGaps || [], + okr3Month: okrData.okr3Month || null, + okr6Month: okrData.okr6Month || null, + okr12Month: okrData.okr12Month || null, + actionPlan: okrData.actionPlan || [], + progressReport: okrData.progressReport || null, + timing: { + totalDurationMs, + okrGenerationMs: okrDurationMs, + }, + provider: `${activeProvider} (${config.llm.model})`, + configSource, + }; + + if (savedTo) { + response.savedTo = savedTo; + } else if (save) { + response.saveWarning = 'OKRs generated but save to disk failed.'; + } + + return { + content: [ + { + type: 'text', + text: JSON.stringify(response, null, 2), + }, + ], + }; + } catch (error) { + const totalDuration = Date.now() - startTime; + context.logger.error(`generate_okr failed after ${totalDuration}ms: ${error}`); + + // Use existing error classification + const classified = classifyError(error); + return createErrorResponse(classified.type, classified.message, { + ...classified.details, + author, + durationMs: totalDuration, + suggestion: classified.suggestion, + }); + } +}; + +// ============================================================================ +// EXPORTS +// ============================================================================ + +/** + * Map tool names to their handlers + */ +export const TOOL_HANDLERS: Record = { + evaluate_commit: handleEvaluateCommit, + check_config: handleCheckConfig, + list_evaluations: handleListEvaluations, + get_report: handleGetReport, + get_author_stats: handleGetAuthorStats, + generate_okr: handleGenerateOkr, + batch_evaluate: handleBatchEvaluate, + compare_commits: handleCompareCommits, + get_team_stats: handleGetTeamStats, + export_report: handleExportReport, +}; + +/** + * Get handler for a tool by name + */ +export function getToolHandler(toolName: string): ContextualToolHandler | undefined { + return TOOL_HANDLERS[toolName]; +} diff --git a/src/mcp-server/tools/tool-registry.ts b/src/mcp-server/tools/tool-registry.ts new file mode 100644 index 0000000..7b05550 --- /dev/null +++ b/src/mcp-server/tools/tool-registry.ts @@ -0,0 +1,349 @@ +/** + * Tool Registry - Central definition of all MCP tools + */ + +import { ToolDefinition } from '../types'; + +/** + * All available tools in the MCP server + * Add new tools here as we implement them + */ +export const TOOLS: Record = { + evaluate_commit: { + name: 'evaluate_commit', + description: + 'Evaluate a git commit using CodeWave multi-agent AI analysis. Returns code quality, complexity, technical debt, test coverage, and functional impact metrics. Can use .codewave.config.json OR pass provider/api_key/model directly.', + inputSchema: { + type: 'object', + properties: { + commit_hash: { + type: 'string', + description: 'Git commit hash to evaluate (e.g., HEAD, abc123, or full SHA)', + }, + repo_path: { + type: 'string', + description: 'Path to git repository (default: current working directory)', + }, + depth: { + type: 'string', + enum: ['fast', 'normal', 'deep'], + description: 'Analysis depth mode: fast (quick), normal (default), deep (thorough)', + }, + provider: { + type: 'string', + enum: ['openai', 'anthropic', 'groq', 'google', 'ollama', 'xai', 'lm-studio'], + description: 'LLM provider (optional - uses config file if not provided)', + }, + api_key: { + type: 'string', + description: 'API key for the LLM provider (optional - uses config file if not provided)', + }, + model: { + type: 'string', + description: 'Model name (optional - uses provider default if not provided)', + }, + save: { + type: 'boolean', + description: 'Save evaluation results to .evaluated-commits folder (default: false). Enable this if you want to use get_author_stats or generate_okr later.', + }, + }, + required: ['commit_hash'], + }, + }, + + // ============================================================================ + // CONFIGURATION TOOLS + // ============================================================================ + + check_config: { + name: 'check_config', + description: + 'Check if CodeWave configuration exists and is valid. Returns provider, model, and enabled agents. Use this before running evaluations to verify setup.', + inputSchema: { + type: 'object', + properties: { + repo_path: { + type: 'string', + description: 'Path to repository (default: current working directory)', + }, + }, + }, + }, + + // ============================================================================ + // REPORT & DATA RETRIEVAL TOOLS + // ============================================================================ + + list_evaluations: { + name: 'list_evaluations', + description: + 'List all commit evaluations from .evaluated-commits folder. Supports filtering by author and date. Use this to browse past evaluations before retrieving specific reports.', + inputSchema: { + type: 'object', + properties: { + repo_path: { + type: 'string', + description: 'Path to repository (default: current working directory)', + }, + author: { + type: 'string', + description: 'Filter by author name (case-insensitive partial match)', + }, + since: { + type: 'string', + description: 'Only evaluations after this date (ISO format: 2024-01-01)', + }, + limit: { + type: 'number', + description: 'Maximum number of results (default: 20, max: 100)', + }, + }, + }, + }, + + get_report: { + name: 'get_report', + description: + 'Get detailed evaluation report for a specific commit. Returns metrics, agent summaries, and concerns. Use list_evaluations first to find available commit hashes.', + inputSchema: { + type: 'object', + properties: { + commit_hash: { + type: 'string', + description: 'Commit hash (short or full) to retrieve report for', + }, + repo_path: { + type: 'string', + description: 'Path to repository (default: current working directory)', + }, + format: { + type: 'string', + enum: ['full', 'summary', 'metrics_only'], + description: 'Response detail level: full (all data), summary (default), metrics_only (just numbers)', + }, + }, + required: ['commit_hash'], + }, + }, + + // ============================================================================ + // ANALYTICS TOOLS + // ============================================================================ + + get_author_stats: { + name: 'get_author_stats', + description: + 'Get aggregated code quality statistics for a developer. Analyzes their evaluated commits to identify strengths, weaknesses, and trends. No LLM calls required.', + inputSchema: { + type: 'object', + properties: { + author: { + type: 'string', + description: 'Author name to analyze (case-insensitive partial match)', + }, + repo_path: { + type: 'string', + description: 'Path to repository (default: current working directory)', + }, + since: { + type: 'string', + description: 'Only include evaluations after this date (ISO format: 2024-01-01)', + }, + limit: { + type: 'number', + description: 'Maximum commits to analyze (default: 50)', + }, + }, + required: ['author'], + }, + }, + + // ============================================================================ + // BATCH OPERATIONS + // ============================================================================ + + batch_evaluate: { + name: 'batch_evaluate', + description: + 'Evaluate multiple commits in a single call. Supports "last:N" syntax (e.g., "last:100") or comma-separated hashes. Returns per-commit results and aggregated summary metrics. Runs up to 10 evaluations in parallel.', + inputSchema: { + type: 'object', + properties: { + commits: { + type: 'string', + description: + 'Commits to evaluate: "last:N" for last N commits, or comma-separated hashes (e.g., "abc123,def456,HEAD")', + }, + repo_path: { + type: 'string', + description: 'Path to git repository (default: current working directory)', + }, + depth: { + type: 'string', + enum: ['fast', 'normal', 'deep'], + description: 'Analysis depth mode: fast (quick), normal (default), deep (thorough)', + }, + save: { + type: 'boolean', + description: 'Save evaluation results to .evaluated-commits folder (default: true)', + }, + provider: { + type: 'string', + enum: ['openai', 'anthropic', 'groq', 'google', 'ollama', 'xai', 'lm-studio'], + description: 'LLM provider (optional - uses config file if not provided)', + }, + api_key: { + type: 'string', + description: 'API key for the LLM provider (optional - uses config file if not provided)', + }, + model: { + type: 'string', + description: 'Model name (optional - uses provider default if not provided)', + }, + }, + required: ['commits'], + }, + }, + + compare_commits: { + name: 'compare_commits', + description: + 'Compare metrics between two commits to show improvement or regression. Reads from saved evaluations (no API calls). Use this to track code quality changes over time.', + inputSchema: { + type: 'object', + properties: { + commit_a: { + type: 'string', + description: 'First commit hash (baseline) - supports partial match', + }, + commit_b: { + type: 'string', + description: 'Second commit hash (to compare against baseline) - supports partial match', + }, + repo_path: { + type: 'string', + description: 'Path to repository (default: current working directory)', + }, + }, + required: ['commit_a', 'commit_b'], + }, + }, + + get_team_stats: { + name: 'get_team_stats', + description: + 'Get aggregated code quality statistics for all authors in a repository. Analyzes all evaluated commits and groups by author. No API calls required - reads from saved evaluations.', + inputSchema: { + type: 'object', + properties: { + repo_path: { + type: 'string', + description: 'Path to repository (default: current working directory)', + }, + since: { + type: 'string', + description: 'Only include evaluations after this date (ISO format: 2024-01-01)', + }, + limit: { + type: 'number', + description: 'Maximum number of authors to return (default: 10)', + }, + }, + }, + }, + + export_report: { + name: 'export_report', + description: + 'Export evaluation report to a file in HTML, Markdown, or JSON format. Reads from saved evaluations and generates formatted output.', + inputSchema: { + type: 'object', + properties: { + commit_hash: { + type: 'string', + description: 'Commit hash to export (supports partial match)', + }, + output_path: { + type: 'string', + description: 'File path where the report will be saved', + }, + format: { + type: 'string', + enum: ['html', 'md', 'json'], + description: 'Output format: html (default), md (markdown), or json', + }, + repo_path: { + type: 'string', + description: 'Path to repository (default: current working directory)', + }, + }, + required: ['commit_hash', 'output_path'], + }, + }, + + // ============================================================================ + // OKR GENERATION TOOLS + // ============================================================================ + + generate_okr: { + name: 'generate_okr', + description: + 'Generate personalized OKRs (Objectives and Key Results) and improvement action plan for a developer using AI analysis. Requires valid configuration with API key. Can optionally save results to disk.', + inputSchema: { + type: 'object', + properties: { + author: { + type: 'string', + description: 'Author name to generate OKRs for', + }, + repo_path: { + type: 'string', + description: 'Path to repository (default: current working directory)', + }, + since: { + type: 'string', + description: 'Only include evaluations after this date', + }, + save: { + type: 'boolean', + description: 'Save OKRs to .evaluated-commits/.okrs/ folder (default: true)', + }, + provider: { + type: 'string', + enum: ['openai', 'anthropic', 'groq', 'google', 'ollama', 'xai', 'lm-studio'], + description: 'LLM provider (optional - uses config file if not provided)', + }, + api_key: { + type: 'string', + description: 'API key for the LLM provider (optional - uses config file if not provided)', + }, + model: { + type: 'string', + description: 'Model name (optional - uses provider default if not provided)', + }, + }, + required: ['author'], + }, + }, +}; + +/** + * Get all tool definitions as array (for ListToolsRequestSchema) + */ +export function getAllTools(): ToolDefinition[] { + return Object.values(TOOLS); +} + +/** + * Get tool definition by name + */ +export function getTool(name: string): ToolDefinition | undefined { + return TOOLS[name]; +} + +/** + * Validate tool name exists + */ +export function isValidTool(name: string): boolean { + return name in TOOLS; +} diff --git a/src/mcp-server/types.ts b/src/mcp-server/types.ts new file mode 100644 index 0000000..1a48387 --- /dev/null +++ b/src/mcp-server/types.ts @@ -0,0 +1,52 @@ +/** + * Type definitions for MCP Server + */ + +import { AppConfig } from '../config/config.interface'; + +/** + * Tool response structure for MCP + */ +export interface ToolResponse { + content: Array<{ + type: 'text' | 'image' | 'resource'; + text?: string; + data?: string; + mimeType?: string; + }>; + isError?: boolean; +} + +/** + * Context passed to all tool handlers + */ +export interface ToolContext { + projectPath: string; + config: AppConfig | null; + logger: { + info: (message: string) => void; + error: (message: string, error?: unknown) => void; + warn: (message: string) => void; + }; +} + +/** + * Tool handler function signature with context + */ +export type ContextualToolHandler = ( + args: Record, + context: ToolContext, +) => Promise; + +/** + * Tool definition with JSON schema + */ +export interface ToolDefinition { + name: string; + description: string; + inputSchema: { + type: 'object'; + properties: Record; + required?: string[]; + }; +} diff --git a/src/orchestrator/commit-evaluation-graph.ts b/src/orchestrator/commit-evaluation-graph.ts index 9e4ca26..de5ed2b 100644 --- a/src/orchestrator/commit-evaluation-graph.ts +++ b/src/orchestrator/commit-evaluation-graph.ts @@ -24,6 +24,7 @@ export const CommitEvaluationState = Annotation.Root({ developerOverview: Annotation, // Developer's description of changes vectorStore: Annotation, // RAG vector store for large diffs documentationStore: Annotation, // Documentation vector store + staticAnalysisSummary: Annotation, // Commit metadata for logging commitHash: Annotation, @@ -415,6 +416,7 @@ export function createCommitEvaluationGraph(agentRegistry: AgentRegistry, config conversationHistory: state.conversationHistory, // Pass full conversation vectorStore: state.vectorStore, // RAG support for large diffs documentationStore: state.documentationStore, // Documentation vector store + staticAnalysisSummary: state.staticAnalysisSummary, // Static analysis findings for prompt context currentRound: state.currentRound, // Current round number (0-indexed) isFinalRound, // Flag indicating if this is the final round teamConcerns: state.teamConcerns, // Concerns raised by team in previous round diff --git a/src/orchestrator/commit-evaluation-orchestrator.ts b/src/orchestrator/commit-evaluation-orchestrator.ts index d55b9ea..8d3777a 100644 --- a/src/orchestrator/commit-evaluation-orchestrator.ts +++ b/src/orchestrator/commit-evaluation-orchestrator.ts @@ -2,6 +2,18 @@ import { createCommitEvaluationGraph } from './commit-evaluation-graph'; // Assu import { formatTokenUsage, formatCost } from '../utils/token-tracker'; // Assume these exist import { DiffVectorStoreService } from '../services/diff-vector-store.service.js'; // Assume this exists import { DocumentationVectorStoreService } from '../services/documentation-vector-store.service'; // Assume this exists +import { + StaticAnalysisOutputMode, + StaticAnalysisResult, + StaticAnalysisService, +} from '../services/static-analysis'; +import { LoggerService } from '../common/logger'; + +const STATIC_ANALYSIS_LOG_CATEGORY = 'static-analysis'; +const STATIC_ANALYSIS_MISSING_TOOLS_WARNING = + 'Static analysis requested, but tools are not installed. Continuing without unavailable analyzers.'; +const STATIC_ANALYSIS_SETUP_HINT = + "Run 'codewave config --init' and choose 'Yes' for static analyzer installation to enable Semgrep/Qlty."; export class CommitEvaluationOrchestrator { constructor( @@ -81,7 +93,13 @@ export class CommitEvaluationOrchestrator { * @returns Object containing agent results and evaluation metadata */ async evaluateCommit(context: any, options: any = {}) { - const { streaming = true, threadId, onProgress, disableTracing = false } = options; + const { + streaming = true, + threadId, + onProgress, + disableTracing = false, + outputMode = 'single' as StaticAnalysisOutputMode, + } = options; console.log('\nπŸš€ Starting commit evaluation with LangGraph workflow...'); const startTime = Date.now(); @@ -117,6 +135,57 @@ export class CommitEvaluationOrchestrator { context.documentationStore = this.documentationStore; } + let staticAnalysis: StaticAnalysisResult | undefined; + const staticAnalysisConfig = this.config.staticAnalysis; + if (staticAnalysisConfig?.enabled) { + try { + staticAnalysis = await StaticAnalysisService.analyze( + context.filesChanged || [], + context.repoPath || process.cwd(), + staticAnalysisConfig, + { + commitDiff: context.commitDiff, + } + ); + StaticAnalysisService.logOutput(staticAnalysis.summary, outputMode); + + const semgrepEnabled = staticAnalysis.summary.available.semgrep; + const qltyEnabled = staticAnalysis.summary.available.qlty; + if (semgrepEnabled || qltyEnabled) { + LoggerService.info( + `Static analysis active (semgrep: ${semgrepEnabled ? 'on' : 'off'}, qlty: ${ + qltyEnabled ? 'on' : 'off' + }).`, + { + category: STATIC_ANALYSIS_LOG_CATEGORY, + } + ); + } else { + if (!staticAnalysis.summary.warnings.includes(STATIC_ANALYSIS_MISSING_TOOLS_WARNING)) { + LoggerService.warn(STATIC_ANALYSIS_MISSING_TOOLS_WARNING, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + } + if (!staticAnalysis.summary.warnings.includes(STATIC_ANALYSIS_SETUP_HINT)) { + LoggerService.info(STATIC_ANALYSIS_SETUP_HINT, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + } + } + } catch (error) { + LoggerService.warn('Static analysis failed; continuing with agent evaluation.', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + data: { + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } else { + LoggerService.info('Running commit evaluation without static analysis (disabled in config).', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + } + // Initialize graph state const initialState = { commitDiff: context.commitDiff, @@ -124,6 +193,7 @@ export class CommitEvaluationOrchestrator { developerOverview: context.developerOverview, // Include developer overview vectorStore: context.vectorStore, // Pass vector store through graph state documentationStore: context.documentationStore, // Pass documentation store through graph state + staticAnalysisSummary: staticAnalysis?.summary, // Pass static analysis findings for agent prompts commitHash: context.commitHash, // NEW: For progress logging commitIndex: context.commitIndex, // NEW: For batch progress totalCommits: context.totalCommits, // NEW: For batch progress @@ -259,6 +329,7 @@ export class CommitEvaluationOrchestrator { totalInputTokens: finalState?.totalInputTokens, totalOutputTokens: finalState?.totalOutputTokens, totalCost: finalState?.totalCost, + staticAnalysis, }; } diff --git a/src/services/static-analysis/__tests__/changed-lines-scope.test.ts b/src/services/static-analysis/__tests__/changed-lines-scope.test.ts new file mode 100644 index 0000000..7770a45 --- /dev/null +++ b/src/services/static-analysis/__tests__/changed-lines-scope.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { + findingMatchesChangedLines, + parseChangedLineRangesFromUnifiedDiff, +} from '../changed-lines-scope'; + +function rangesToObject(result: ReturnType) { + return Object.fromEntries(result.rangesByFile.entries()); +} + +describe('changed-lines-scope', () => { + it('parses changed line ranges from unified diff across files and hunks', () => { + const diff = `diff --git a/src/a.ts b/src/a.ts +index 1111111..2222222 100644 +--- a/src/a.ts ++++ b/src/a.ts +@@ -1,3 +1,4 @@ + const one = 1; +-const two = 2; ++const two = 20; ++const three = 3; + const four = 4; +@@ -10,2 +11,3 @@ + const x = 1; ++const y = 2; + const z = 3; +diff --git a/src/new.ts b/src/new.ts +new file mode 100644 +index 0000000..3333333 +--- /dev/null ++++ b/src/new.ts +@@ -0,0 +1,2 @@ ++export const created = true; ++export const value = 2; +diff --git a/src/old.ts b/src/old.ts +deleted file mode 100644 +index 4444444..0000000 +--- a/src/old.ts ++++ /dev/null +@@ -1,2 +0,0 @@ +-export const oldOne = 1; +-export const oldTwo = 2; +`; + + const parsed = parseChangedLineRangesFromUnifiedDiff(diff); + + expect(parsed.parseable).toBe(true); + expect(rangesToObject(parsed)).toEqual({ + 'src/a.ts': [ + { start: 2, end: 3 }, + { start: 12, end: 12 }, + ], + 'src/new.ts': [{ start: 1, end: 2 }], + }); + expect(parsed.rangesByFile.has('src/old.ts')).toBe(false); + }); + + it('returns parseable false for non-diff content', () => { + const parsed = parseChangedLineRangesFromUnifiedDiff('feat: update files'); + + expect(parsed.parseable).toBe(false); + expect(parsed.rangesByFile.size).toBe(0); + }); + + it('matches finding file+line to changed ranges using repo-relative normalization', () => { + const diff = `diff --git a/src/a.ts b/src/a.ts +index 1111111..2222222 100644 +--- a/src/a.ts ++++ b/src/a.ts +@@ -1,2 +1,3 @@ + export const a = 1; ++export const b = 2; + export const c = 3; +`; + const parsed = parseChangedLineRangesFromUnifiedDiff(diff); + + expect( + findingMatchesChangedLines('/repo/src/a.ts', 2, parsed.rangesByFile, '/repo') + ).toBe(true); + expect( + findingMatchesChangedLines('/repo/src/a.ts', 3, parsed.rangesByFile, '/repo') + ).toBe(false); + }); + + it('parses C-escaped quoted git paths and matches decoded filenames', () => { + const diff = `diff --git "a/src/old\\303\\251.ts" "b/src/new\\303\\251.ts" +index 1111111..2222222 100644 +--- "a/src/old\\303\\251.ts" ++++ "b/src/new\\303\\251.ts" +@@ -1,2 +1,3 @@ + export const a = 1; ++export const b = 2; + export const c = 3; +`; + const parsed = parseChangedLineRangesFromUnifiedDiff(diff); + + expect(parsed.parseable).toBe(true); + expect(rangesToObject(parsed)).toEqual({ + 'src/newΓ©.ts': [{ start: 2, end: 2 }], + }); + expect( + findingMatchesChangedLines('/repo/src/newΓ©.ts', 2, parsed.rangesByFile, '/repo') + ).toBe(true); + }); + + it('parses quoted git paths with escaped quotes', () => { + const diff = String.raw`diff --git "a/src/old\"name.ts" "b/src/new\"name.ts" +index 1111111..2222222 100644 +--- "a/src/old\"name.ts" ++++ "b/src/new\"name.ts" +@@ -1 +1 @@ +-const value = "old"; ++const value = "new"; +`; + const parsed = parseChangedLineRangesFromUnifiedDiff(diff); + + expect(parsed.parseable).toBe(true); + expect(rangesToObject(parsed)).toEqual({ + 'src/new"name.ts': [{ start: 1, end: 1 }], + }); + expect( + findingMatchesChangedLines('/repo/src/new"name.ts', 1, parsed.rangesByFile, '/repo') + ).toBe(true); + }); +}); diff --git a/src/services/static-analysis/__tests__/file-scope-resolver.test.ts b/src/services/static-analysis/__tests__/file-scope-resolver.test.ts new file mode 100644 index 0000000..52559fb --- /dev/null +++ b/src/services/static-analysis/__tests__/file-scope-resolver.test.ts @@ -0,0 +1,119 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { LoggerService } from '../../../common/logger'; +import { SilentTransport } from '../../../common/logger/transports/silent.transport'; +import { FileScopeResolver } from '../file-scope-resolver'; + +describe('FileScopeResolver', () => { + let repoRoot: string; + + beforeEach(() => { + repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'file-scope-resolver-')); + + fs.mkdirSync(path.join(repoRoot, 'src'), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, 'test'), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, 'tests'), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, 'src', '__tests__'), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, 'node_modules', 'pkg'), { recursive: true }); + + fs.writeFileSync(path.join(repoRoot, 'src', 'app.ts'), 'export const ok = true;\n'); + fs.writeFileSync(path.join(repoRoot, 'src', 'gen.generated.ts'), 'export const gen = true;\n'); + fs.writeFileSync(path.join(repoRoot, 'src', 'app.test.ts'), 'test("x", () => {});\n'); + fs.writeFileSync(path.join(repoRoot, 'src', 'app.spec.js'), 'describe("x", () => {});\n'); + fs.writeFileSync(path.join(repoRoot, 'src', '__tests__', 'resolver.py'), 'def test_x():\n pass\n'); + fs.writeFileSync(path.join(repoRoot, 'test', 'integration_test.go'), 'package test\n'); + fs.writeFileSync(path.join(repoRoot, 'tests', 'suite_test.rb'), 'puts :ok\n'); + fs.writeFileSync(path.join(repoRoot, 'node_modules', 'pkg', 'index.js'), 'module.exports = {};\n'); + + LoggerService.setTransport(new SilentTransport()); + }); + + afterEach(() => { + LoggerService.resetForTests(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + }); + + it('returns existing repo-relative files in scanFiles', () => { + const result = FileScopeResolver.resolve(['src/app.ts'], repoRoot, []); + expect(result.scanFiles).toEqual(['src/app.ts']); + expect(result.skippedMissing).toEqual([]); + expect(result.skippedExcluded).toEqual([]); + expect(result.skippedUnsupported).toEqual([]); + }); + + it('deduplicates paths and normalizes absolute/relative input', () => { + const absolutePath = path.join(repoRoot, 'src', 'app.ts'); + const result = FileScopeResolver.resolve(['src/app.ts', './src/app.ts', absolutePath], repoRoot, []); + + expect(result.scanFiles).toEqual(['src/app.ts']); + expect(result.skippedMissing).toEqual([]); + expect(result.skippedExcluded).toEqual([]); + }); + + it('tracks missing files without failing the whole resolution', () => { + const result = FileScopeResolver.resolve(['src/app.ts', 'src/missing.ts'], repoRoot, []); + expect(result.scanFiles).toEqual(['src/app.ts']); + expect(result.skippedMissing).toEqual(['src/missing.ts']); + }); + + it('applies exclude patterns and reports skippedExcluded', () => { + const result = FileScopeResolver.resolve( + ['src/app.ts', 'src/gen.generated.ts', 'node_modules/pkg/index.js'], + repoRoot, + ['node_modules/**', '**/*.generated.ts'] + ); + + expect(result.scanFiles).toEqual(['src/app.ts']); + expect(result.skippedExcluded).toContain('src/gen.generated.ts'); + expect(result.skippedExcluded).toContain('node_modules/pkg/index.js'); + expect(result.skippedMissing).toEqual([]); + }); + + it('excludes test files and test directories across languages', () => { + const result = FileScopeResolver.resolve( + [ + 'src/app.ts', + 'src/app.test.ts', + 'src/app.spec.js', + 'src/__tests__/resolver.py', + 'test/integration_test.go', + 'tests/suite_test.rb', + ], + repoRoot, + [ + '**/__tests__/**', + '**/test/**', + '**/tests/**', + '**/*.test.*', + '**/*.spec.*', + '**/*_test.*', + '**/*_spec.*', + ] + ); + + expect(result.scanFiles).toEqual(['src/app.ts']); + expect(result.skippedExcluded).toContain('src/app.test.ts'); + expect(result.skippedExcluded).toContain('src/app.spec.js'); + expect(result.skippedExcluded).toContain('src/__tests__/resolver.py'); + expect(result.skippedExcluded).toContain('test/integration_test.go'); + expect(result.skippedExcluded).toContain('tests/suite_test.rb'); + }); + + it('marks out-of-repo paths as unsupported', () => { + const outsideAbsolute = path.resolve(repoRoot, '..', 'outside.ts'); + const result = FileScopeResolver.resolve(['../outside.ts', outsideAbsolute], repoRoot, []); + + expect(result.scanFiles).toEqual([]); + expect(result.skippedUnsupported).toEqual(['../outside.ts', outsideAbsolute]); + }); + + it('returns empty arrays for empty input', () => { + const result = FileScopeResolver.resolve([], repoRoot, ['node_modules/**']); + expect(result.scanFiles).toEqual([]); + expect(result.skippedMissing).toEqual([]); + expect(result.skippedExcluded).toEqual([]); + expect(result.skippedUnsupported).toEqual([]); + }); +}); diff --git a/src/services/static-analysis/__tests__/finding-formatter.test.ts b/src/services/static-analysis/__tests__/finding-formatter.test.ts new file mode 100644 index 0000000..238c65d --- /dev/null +++ b/src/services/static-analysis/__tests__/finding-formatter.test.ts @@ -0,0 +1,434 @@ +import { describe, it, expect } from 'vitest'; +import { + formatFindingLine, + buildRiskProfile, + filterFindingsForAgent, + formatForAgent, + buildRound2Reference, + CATEGORY_ROUTING, + MAX_FINDINGS_SAFETY_CAP, + type AgentName, +} from '../finding-formatter'; +import type { StaticAnalysisFinding, StaticAnalysisSummary } from '../types'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeFinding(overrides: Partial = {}): StaticAnalysisFinding { + return { + source: 'semgrep', + runnerId: 'semgrep', + file: 'src/app.ts', + line: 10, + column: 1, + message: 'Some issue found', + severity: 'warning', + category: 'quality', + ruleId: 'test-rule', + ...overrides, + }; +} + +function makeSummary(overrides: Partial = {}): StaticAnalysisSummary { + const findings = overrides.findings ?? []; + const errors = findings.filter((f) => f.severity === 'error').length; + const warnings = findings.filter((f) => f.severity === 'warning').length; + const info = findings.filter((f) => f.severity === 'info').length; + + const byCategory: Record = {}; + const byFile: Record = {}; + for (const f of findings) { + byCategory[f.category] = (byCategory[f.category] || 0) + 1; + byFile[f.file] = (byFile[f.file] || 0) + 1; + } + + return { + available: { semgrep: true, qlty: true }, + timing: { semgrepMs: 100, qltyMs: 100, totalMs: 200 }, + counts: { + total: findings.length, + errors, + warnings, + info, + byCategory, + byFile, + byRunner: { semgrep: findings.length, 'qlty-check': 0, 'qlty-smells': 0 }, + }, + findings, + truncated: false, + totalBeforeCap: findings.length, + skippedFiles: { missing: [], excluded: [], unsupported: [] }, + warnings: [], + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// formatFindingLine +// --------------------------------------------------------------------------- + +describe('formatFindingLine', () => { + it('formats a basic finding', () => { + const line = formatFindingLine(makeFinding()); + expect(line).toBe( + '[WARNING][quality] src/app.ts:10 - Some issue found (semgrep:test-rule)' + ); + }); + + it('includes CWE suffix when present', () => { + const line = formatFindingLine(makeFinding({ cwe: ['CWE-89', 'CWE-79'] })); + expect(line).toContain('[CWE-89]'); + expect(line).not.toContain('[CWE-79]'); // Only first + }); + + it('includes OWASP suffix when present', () => { + const line = formatFindingLine(makeFinding({ owasp: ['A01:2021'] })); + expect(line).toContain('[A01:2021]'); + }); + + it('includes fixable suffix when true', () => { + const line = formatFindingLine(makeFinding({ fixable: true })); + expect(line).toContain('[fixable]'); + }); + + it('does not include fixable suffix when false or undefined', () => { + expect(formatFindingLine(makeFinding({ fixable: false }))).not.toContain('[fixable]'); + expect(formatFindingLine(makeFinding())).not.toContain('[fixable]'); + }); + + it('includes all suffixes together', () => { + const line = formatFindingLine( + makeFinding({ cwe: ['CWE-89'], owasp: ['A03:2021'], fixable: true }) + ); + expect(line).toContain('[CWE-89]'); + expect(line).toContain('[A03:2021]'); + expect(line).toContain('[fixable]'); + }); + + it('truncates long messages at 120 chars', () => { + const longMessage = 'A'.repeat(200); + const line = formatFindingLine(makeFinding({ message: longMessage })); + expect(line).toContain('A'.repeat(120) + '...'); + expect(line).not.toContain('A'.repeat(121)); + }); + + it('does not truncate messages at exactly 120 chars', () => { + const exactMessage = 'B'.repeat(120); + const line = formatFindingLine(makeFinding({ message: exactMessage })); + expect(line).toContain(exactMessage); + expect(line).not.toContain('...'); + }); +}); + +// --------------------------------------------------------------------------- +// buildRiskProfile +// --------------------------------------------------------------------------- + +describe('buildRiskProfile', () => { + it('returns LOW risk for zero findings', () => { + const profile = buildRiskProfile(makeSummary({ findings: [] })); + expect(profile).toContain('Risk: LOW'); + expect(profile).toContain('0 total'); + }); + + it('returns HIGH risk for 3+ errors', () => { + const findings = [ + makeFinding({ severity: 'error' }), + makeFinding({ severity: 'error', file: 'b.ts' }), + makeFinding({ severity: 'error', file: 'c.ts' }), + ]; + const profile = buildRiskProfile(makeSummary({ findings })); + expect(profile).toContain('Risk: HIGH'); + expect(profile).toContain('3 errors detected'); + }); + + it('returns MEDIUM risk for 1-2 errors', () => { + const findings = [makeFinding({ severity: 'error' })]; + const profile = buildRiskProfile(makeSummary({ findings })); + expect(profile).toContain('Risk: MEDIUM'); + }); + + it('returns MODERATE risk for 5+ warnings with no errors', () => { + const findings = Array.from({ length: 5 }, (_, i) => + makeFinding({ severity: 'warning', file: `f${i}.ts` }) + ); + const profile = buildRiskProfile(makeSummary({ findings })); + expect(profile).toContain('Risk: MODERATE'); + }); + + it('returns LOW risk for few warnings and no errors', () => { + const findings = [makeFinding({ severity: 'warning' })]; + const profile = buildRiskProfile(makeSummary({ findings })); + expect(profile).toContain('Risk: LOW'); + }); + + it('includes category breakdown', () => { + const findings = [ + makeFinding({ category: 'security', severity: 'error' }), + makeFinding({ category: 'quality' }), + makeFinding({ category: 'quality', file: 'b.ts' }), + ]; + const profile = buildRiskProfile(makeSummary({ findings })); + expect(profile).toContain('security=1'); + expect(profile).toContain('quality=2'); + }); + + it('includes hotspot files sorted by count', () => { + const findings = [ + makeFinding({ file: 'src/auth.ts' }), + makeFinding({ file: 'src/auth.ts', line: 20 }), + makeFinding({ file: 'src/auth.ts', line: 30 }), + makeFinding({ file: 'src/db.ts' }), + ]; + const profile = buildRiskProfile(makeSummary({ findings })); + expect(profile).toContain('src/auth.ts (3)'); + expect(profile).toContain('src/db.ts (1)'); + }); +}); + +// --------------------------------------------------------------------------- +// filterFindingsForAgent +// --------------------------------------------------------------------------- + +describe('filterFindingsForAgent', () => { + const securityError = makeFinding({ category: 'security', severity: 'error', file: 'a.ts' }); + const securityWarning = makeFinding({ category: 'security', severity: 'warning', file: 'b.ts' }); + const securityInfo = makeFinding({ category: 'security', severity: 'info', file: 'c.ts' }); + const qualityWarning = makeFinding({ category: 'quality', severity: 'warning', file: 'd.ts' }); + const qualityInfo = makeFinding({ category: 'quality', severity: 'info', file: 'e.ts' }); + const bugError = makeFinding({ category: 'bug', severity: 'error', file: 'f.ts' }); + const styleInfo = makeFinding({ category: 'style', severity: 'info', file: 'g.ts' }); + const complexityWarning = makeFinding({ category: 'complexity', severity: 'warning', file: 'h.ts' }); + + const allFindings = [ + securityError, securityWarning, securityInfo, + qualityWarning, qualityInfo, + bugError, styleInfo, complexityWarning, + ]; + + it('senior-architect gets all security/complexity/duplication severities (primary)', () => { + const filtered = filterFindingsForAgent(allFindings, 'senior-architect'); + // Primary: security (all sev) + complexity (all sev) + duplication (all sev) + expect(filtered).toContainEqual(securityError); + expect(filtered).toContainEqual(securityWarning); + expect(filtered).toContainEqual(securityInfo); + expect(filtered).toContainEqual(complexityWarning); + }); + + it('developer-reviewer is primary for quality/style/bug', () => { + const filtered = filterFindingsForAgent(allFindings, 'developer-reviewer'); + expect(filtered).toContainEqual(qualityWarning); + expect(filtered).toContainEqual(qualityInfo); // Primary gets info + expect(filtered).toContainEqual(styleInfo); // Primary for style + expect(filtered).toContainEqual(bugError); + }); + + it('developer-reviewer is secondary for security/complexity/duplication β€” no info', () => { + const filtered = filterFindingsForAgent(allFindings, 'developer-reviewer'); + expect(filtered).toContainEqual(securityError); + expect(filtered).toContainEqual(securityWarning); + expect(filtered).not.toContainEqual(securityInfo); // Secondary: no info + expect(filtered).toContainEqual(complexityWarning); + }); + + it('business-analyst gets security as secondary β€” no info', () => { + const filtered = filterFindingsForAgent(allFindings, 'business-analyst'); + expect(filtered).toContainEqual(securityError); + expect(filtered).toContainEqual(securityWarning); + expect(filtered).not.toContainEqual(securityInfo); + // Should NOT get quality, bug, style, complexity + expect(filtered).not.toContainEqual(qualityWarning); + expect(filtered).not.toContainEqual(bugError); + }); + + it('sdet gets bug as secondary β€” no info', () => { + const filtered = filterFindingsForAgent(allFindings, 'sdet'); + expect(filtered).toContainEqual(bugError); + // Should NOT get quality, security, style, complexity + expect(filtered).not.toContainEqual(qualityWarning); + expect(filtered).not.toContainEqual(securityError); + }); + + it('sorts by severity desc -> file -> line', () => { + const findings = [ + makeFinding({ severity: 'info', file: 'z.ts', line: 1, category: 'quality' }), + makeFinding({ severity: 'error', file: 'a.ts', line: 5, category: 'quality' }), + makeFinding({ severity: 'warning', file: 'a.ts', line: 1, category: 'quality' }), + makeFinding({ severity: 'error', file: 'a.ts', line: 1, category: 'quality' }), + ]; + const filtered = filterFindingsForAgent(findings, 'developer-reviewer'); + expect(filtered[0].severity).toBe('error'); + expect(filtered[0].line).toBe(1); + expect(filtered[1].severity).toBe('error'); + expect(filtered[1].line).toBe(5); + expect(filtered[2].severity).toBe('warning'); + expect(filtered[3].severity).toBe('info'); + }); + + it('returns empty array when no categories match', () => { + const findings = [makeFinding({ category: 'style', severity: 'info' })]; + // business-analyst has no routing for style + const filtered = filterFindingsForAgent(findings, 'business-analyst'); + expect(filtered).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// formatForAgent +// --------------------------------------------------------------------------- + +describe('formatForAgent', () => { + it('returns formatted section with risk profile and findings', () => { + const findings = [ + makeFinding({ category: 'quality', severity: 'error' }), + makeFinding({ category: 'quality', severity: 'warning', file: 'b.ts' }), + ]; + const result = formatForAgent(makeSummary({ findings }), 'developer-reviewer'); + expect(result.agentFindings).toContain('## Additional Context: Static Analysis Tool Results'); + expect(result.agentFindings).toContain('--- Risk Profile ---'); + expect(result.agentFindings).toContain('Findings Relevant to Your Expertise'); + expect(result.findingsIncluded).toBe(2); + expect(result.totalFindings).toBe(2); + }); + + it('shows "No findings relevant" when agent has no matching findings', () => { + const findings = [makeFinding({ category: 'style', severity: 'info' })]; + const result = formatForAgent(makeSummary({ findings }), 'business-analyst'); + expect(result.agentFindings).toContain('No findings relevant to your expertise'); + expect(result.findingsIncluded).toBe(0); + }); + + it('includes all filtered findings without truncation', () => { + const findings = Array.from({ length: 50 }, (_, i) => + makeFinding({ category: 'quality', severity: 'warning', file: `f${i}.ts`, line: i }) + ); + const result = formatForAgent(makeSummary({ findings }), 'developer-reviewer'); + expect(result.findingsIncluded).toBe(50); + expect(result.agentFindings).not.toContain('additional findings omitted'); + }); + + it('respects safety cap at MAX_FINDINGS_SAFETY_CAP', () => { + const findings = Array.from({ length: 250 }, (_, i) => + makeFinding({ category: 'quality', severity: 'warning', file: `f${i}.ts`, line: i }) + ); + const result = formatForAgent(makeSummary({ findings }), 'developer-reviewer'); + expect(result.findingsIncluded).toBe(MAX_FINDINGS_SAFETY_CAP); + expect(result.agentFindings).toContain('additional findings omitted'); + }); + + it('handles empty findings array', () => { + const result = formatForAgent(makeSummary({ findings: [] }), 'developer-reviewer'); + expect(result.agentFindings).toContain('No findings relevant to your expertise'); + expect(result.findingsIncluded).toBe(0); + expect(result.totalFindings).toBe(0); + }); + + it('provides reasonable token estimate', () => { + const findings = [makeFinding({ category: 'quality', severity: 'error' })]; + const result = formatForAgent(makeSummary({ findings }), 'developer-reviewer'); + expect(result.estimatedTokens).toBeGreaterThan(0); + // Rough check: ~4 chars per token + expect(result.estimatedTokens).toBeLessThanOrEqual(result.agentFindings.length); + }); +}); + +// --------------------------------------------------------------------------- +// buildRound2Reference +// --------------------------------------------------------------------------- + +describe('buildRound2Reference', () => { + it('contains risk profile and Round 2+ header', () => { + const ref = buildRound2Reference(makeSummary({ findings: [] })); + expect(ref).toContain('Static Analysis Reference (Round 2+)'); + expect(ref).toContain('Full findings were provided in Round 1'); + expect(ref).toContain('--- Risk Profile ---'); + }); + + it('includes error-severity findings', () => { + const findings = [ + makeFinding({ severity: 'error', category: 'security', message: 'SQL injection' }), + makeFinding({ severity: 'warning', category: 'quality', message: 'Unused var' }), + makeFinding({ severity: 'info', category: 'style', message: 'Trailing space' }), + ]; + const ref = buildRound2Reference(makeSummary({ findings })); + expect(ref).toContain('Critical Findings (errors only)'); + expect(ref).toContain('SQL injection'); + expect(ref).not.toContain('Unused var'); + expect(ref).not.toContain('Trailing space'); + }); + + it('does not include critical findings section when no errors', () => { + const findings = [makeFinding({ severity: 'warning' })]; + const ref = buildRound2Reference(makeSummary({ findings })); + expect(ref).not.toContain('Critical Findings'); + }); +}); + +// --------------------------------------------------------------------------- +// Integration: formatForAgent per agent role +// --------------------------------------------------------------------------- + +describe('integration: formatForAgent routes correctly per agent', () => { + const mixedFindings: StaticAnalysisFinding[] = [ + makeFinding({ category: 'security', severity: 'error', file: 'auth.ts', message: 'SQL injection' }), + makeFinding({ category: 'security', severity: 'info', file: 'auth.ts', line: 20, message: 'Low-risk header' }), + makeFinding({ category: 'quality', severity: 'warning', file: 'utils.ts', message: 'Complex function' }), + makeFinding({ category: 'quality', severity: 'info', file: 'utils.ts', line: 50, message: 'Style hint' }), + makeFinding({ category: 'bug', severity: 'error', file: 'core.ts', message: 'Null deref' }), + makeFinding({ category: 'style', severity: 'info', file: 'index.ts', message: 'Trailing whitespace' }), + makeFinding({ category: 'complexity', severity: 'warning', file: 'logic.ts', message: 'High cyclomatic' }), + makeFinding({ category: 'duplication', severity: 'warning', file: 'dup.ts', message: 'Code clone' }), + ]; + const summary = makeSummary({ findings: mixedFindings }); + + it('senior-architect sees security+complexity+duplication (primary, all sev)', () => { + const result = formatForAgent(summary, 'senior-architect'); + expect(result.agentFindings).toContain('SQL injection'); + expect(result.agentFindings).toContain('Low-risk header'); // info from primary category + expect(result.agentFindings).toContain('High cyclomatic'); + expect(result.agentFindings).toContain('Code clone'); + // Should NOT see quality/bug/style primary findings + expect(result.agentFindings).not.toContain('Trailing whitespace'); + }); + + it('developer-reviewer sees quality+style+bug (primary) + security+complexity+duplication (secondary, no info)', () => { + const result = formatForAgent(summary, 'developer-reviewer'); + expect(result.agentFindings).toContain('Complex function'); + expect(result.agentFindings).toContain('Style hint'); // primary quality info + expect(result.agentFindings).toContain('Null deref'); + expect(result.agentFindings).toContain('Trailing whitespace'); // primary style info + expect(result.agentFindings).toContain('SQL injection'); // secondary security error + expect(result.agentFindings).not.toContain('Low-risk header'); // secondary security info filtered out + }); + + it('business-analyst sees only security (secondary, no info)', () => { + const result = formatForAgent(summary, 'business-analyst'); + expect(result.agentFindings).toContain('SQL injection'); + expect(result.agentFindings).not.toContain('Low-risk header'); + expect(result.agentFindings).not.toContain('Complex function'); + expect(result.agentFindings).not.toContain('Null deref'); + }); + + it('sdet sees only bug (secondary, no info)', () => { + const result = formatForAgent(summary, 'sdet'); + expect(result.agentFindings).toContain('Null deref'); + expect(result.agentFindings).not.toContain('SQL injection'); + expect(result.agentFindings).not.toContain('Complex function'); + }); + + it('developer-author sees quality+complexity+bug (secondary, no info)', () => { + const result = formatForAgent(summary, 'developer-author'); + expect(result.agentFindings).toContain('Complex function'); + expect(result.agentFindings).not.toContain('Style hint'); // quality info filtered (secondary) + expect(result.agentFindings).toContain('Null deref'); + expect(result.agentFindings).toContain('High cyclomatic'); + }); + + it('graceful degradation: undefined-like empty summary produces no findings section', () => { + const emptySummary = makeSummary({ findings: [] }); + const result = formatForAgent(emptySummary, 'developer-reviewer'); + expect(result.agentFindings).toContain('No findings relevant'); + expect(result.findingsIncluded).toBe(0); + }); +}); diff --git a/src/services/static-analysis/__tests__/qlty-runner.test.ts b/src/services/static-analysis/__tests__/qlty-runner.test.ts new file mode 100644 index 0000000..fd7b2ab --- /dev/null +++ b/src/services/static-analysis/__tests__/qlty-runner.test.ts @@ -0,0 +1,567 @@ +import fs, { PathLike } from 'fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LoggerService } from '../../../common/logger'; +import { SilentTransport } from '../../../common/logger/transports/silent.transport'; +import { QltyRunner } from '../qlty-runner'; +import { spawnAsync } from '../spawn-helper'; + +vi.mock('../spawn-helper', () => ({ + spawnAsync: vi.fn(), +})); + +const mockedSpawnAsync = vi.mocked(spawnAsync); +let existsSyncSpy: ReturnType; + +describe('QltyRunner', () => { + beforeEach(() => { + mockedSpawnAsync.mockReset(); + existsSyncSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(false); + LoggerService.setTransport(new SilentTransport()); + LoggerService.setLevel('debug'); + }); + + afterEach(() => { + existsSyncSpy.mockRestore(); + LoggerService.resetForTests(); + }); + + it('parses valid SARIF output into unified findings', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ + runs: [ + { + tool: { + driver: { + rules: [ + { + id: 'qlty/security/sql-injection', + properties: { tags: ['security'] }, + }, + ], + }, + }, + results: [ + { + ruleId: 'qlty/security/sql-injection', + level: 'warning', + message: { text: 'Possible SQL injection' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/db.ts' }, + region: { startLine: 12, startColumn: 7 }, + }, + }, + ], + fixes: [{}], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const runner = new QltyRunner('/usr/bin/qlty'); + const result = await runner.scan(['src/db.ts'], '/repo', 30_000); + + expect(mockedSpawnAsync).toHaveBeenCalledWith( + '/usr/bin/qlty', + ['check', '--sarif', '--no-progress', 'src/db.ts'], + { cwd: '/repo', timeout: 30_000 } + ); + expect(result.findings).toEqual([ + { + source: 'qlty', + runnerId: 'qlty-check', + file: 'src/db.ts', + line: 12, + column: 7, + message: 'Possible SQL injection', + severity: 'warning', + category: 'security', + ruleId: 'qlty/security/sql-injection', + fixable: true, + }, + ]); + }); + + it('parses findings even when qlty exits non-zero', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ + runs: [ + { + results: [ + { + ruleId: 'qlty/bug/null-check', + level: 'error', + message: { text: 'Potential null dereference' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/service.ts' }, + region: { startLine: 4, startColumn: 2 }, + }, + }, + ], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 2, + timedOut: false, + }); + + const runner = new QltyRunner('/usr/bin/qlty'); + const result = await runner.scan(['src/service.ts'], '/repo', 15_000); + + expect(result.findings).toHaveLength(1); + expect(result.findings[0]).toMatchObject({ + severity: 'error', + category: 'bug', + ruleId: 'qlty/bug/null-check', + }); + expect(result.rawOutput.exitCode).toBe(2); + }); + + it('returns empty findings when qlty scan times out', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: '', + stderr: 'timed out', + exitCode: null, + timedOut: true, + }); + + const runner = new QltyRunner('/usr/bin/qlty'); + const result = await runner.scan(['src/a.ts'], '/repo', 1_000); + + expect(result.findings).toEqual([]); + expect(result.rawOutput.timedOut).toBe(true); + }); + + it('falls back to text parsing when SARIF is malformed', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: 'src/metrics.ts:22:5 warning [complexity] Cyclomatic complexity exceeds threshold', + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const runner = new QltyRunner('/usr/bin/qlty'); + const result = await runner.scan(['src/metrics.ts'], '/repo', 5_000); + + expect(result.findings).toHaveLength(1); + expect(result.findings[0]).toMatchObject({ + file: 'src/metrics.ts', + line: 22, + column: 5, + severity: 'warning', + category: 'complexity', + message: 'Cyclomatic complexity exceeds threshold', + ruleId: 'qlty.text.complexity', + }); + }); + + it('runs qlty init and retries check once when init is required', async () => { + mockedSpawnAsync + .mockResolvedValueOnce({ + stdout: '', + stderr: 'Project not initialized. Please run qlty init before qlty check.', + exitCode: 2, + timedOut: false, + }) + .mockResolvedValueOnce({ + stdout: 'Initialized', + stderr: '', + exitCode: 0, + timedOut: false, + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + runs: [ + { + results: [ + { + ruleId: 'qlty/security/rule', + level: 'warning', + message: { text: 'security issue' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 9, startColumn: 1 }, + }, + }, + ], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const runner = new QltyRunner('/usr/bin/qlty'); + const result = await runner.scan(['src/app.ts'], '/repo', 30_000); + + expect(mockedSpawnAsync).toHaveBeenCalledTimes(3); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith( + 1, + '/usr/bin/qlty', + ['check', '--sarif', '--no-progress', 'src/app.ts'], + { cwd: '/repo', timeout: 30_000 } + ); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith( + 2, + '/usr/bin/qlty', + ['init'], + { cwd: '/repo', timeout: 30_000 } + ); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith( + 3, + '/usr/bin/qlty', + ['check', '--sarif', '--no-progress', 'src/app.ts'], + { cwd: '/repo', timeout: 30_000 } + ); + expect(result.findings).toHaveLength(1); + }); + + it('accepts qlty init with non-zero exit when config file was created', async () => { + existsSyncSpy.mockImplementation((targetPath: PathLike) => + String(targetPath).endsWith('/.qlty/qlty.toml') + ); + + mockedSpawnAsync + .mockResolvedValueOnce({ + stdout: '', + stderr: 'Qlty must be set up in this repository. Try: qlty init', + exitCode: 99, + timedOut: false, + }) + .mockResolvedValueOnce({ + stdout: 'initialized with warning', + stderr: 'IO error: not a terminal', + exitCode: 99, + timedOut: false, + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + runs: [ + { + results: [ + { + ruleId: 'qlty/style/rule', + level: 'warning', + message: { text: 'format issue' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 1, startColumn: 1 }, + }, + }, + ], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 1, + timedOut: false, + }); + + const runner = new QltyRunner('/usr/bin/qlty'); + const result = await runner.scan(['src/app.ts'], '/repo', 30_000); + + expect(mockedSpawnAsync).toHaveBeenCalledTimes(3); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith( + 1, + '/usr/bin/qlty', + ['check', '--sarif', '--no-progress', 'src/app.ts'], + { cwd: '/repo', timeout: 30_000 } + ); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith(2, '/usr/bin/qlty', ['init'], { + cwd: '/repo', + timeout: 30_000, + }); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith( + 3, + '/usr/bin/qlty', + ['check', '--sarif', '--no-progress', 'src/app.ts'], + { cwd: '/repo', timeout: 30_000 } + ); + expect(result.findings).toHaveLength(1); + }); + + it('returns empty findings when qlty init fails and does not throw', async () => { + mockedSpawnAsync + .mockResolvedValueOnce({ + stdout: '', + stderr: 'Project is not initialized. Run qlty init', + exitCode: 2, + timedOut: false, + }) + .mockResolvedValueOnce({ + stdout: '', + stderr: 'init failed', + exitCode: 1, + timedOut: false, + }); + + const runner = new QltyRunner('/usr/bin/qlty'); + const result = await runner.scan(['src/app.ts'], '/repo', 20_000); + + expect(mockedSpawnAsync).toHaveBeenCalledTimes(2); + expect(result.findings).toEqual([]); + expect(result.rawOutput.exitCode).toBe(2); + }); + + it('maps category and severity combinations deterministically', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ + runs: [ + { + tool: { + driver: { + rules: [ + { id: 'rule.maintainability', properties: { tags: ['maintainability'] } }, + { id: 'rule.correctness', properties: { tags: ['correctness'] } }, + { id: 'rule.style', properties: { tags: ['formatting'] } }, + ], + }, + }, + results: [ + { + ruleId: 'rule.maintainability', + level: 'error', + message: { text: 'Maintainability issue' }, + locations: [{ physicalLocation: { artifactLocation: { uri: 'src/a.ts' }, region: { startLine: 1, startColumn: 1 } } }], + }, + { + ruleId: 'rule.correctness', + level: 'note', + message: { text: 'Correctness note' }, + locations: [{ physicalLocation: { artifactLocation: { uri: 'src/b.ts' }, region: { startLine: 2, startColumn: 2 } } }], + }, + { + ruleId: 'rule.style', + level: 'none', + message: { text: 'Formatting info' }, + locations: [{ physicalLocation: { artifactLocation: { uri: 'src/c.ts' }, region: { startLine: 3, startColumn: 3 } } }], + }, + { + ruleId: 'rule.unknown', + level: 'warning', + message: { text: 'duplication detected in block' }, + locations: [{ physicalLocation: { artifactLocation: { uri: 'src/d.ts' }, region: { startLine: 4, startColumn: 4 } } }], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const runner = new QltyRunner('/usr/bin/qlty'); + const result = await runner.scan(['src/a.ts', 'src/b.ts', 'src/c.ts', 'src/d.ts'], '/repo', 10_000); + + expect(result.findings.map((finding) => finding.severity)).toEqual([ + 'error', + 'info', + 'info', + 'warning', + ]); + expect(result.findings.map((finding) => finding.category)).toEqual([ + 'quality', + 'bug', + 'style', + 'duplication', + ]); + }); + + it('sets fixable when SARIF fixes or fixable property is present', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ + runs: [ + { + results: [ + { + ruleId: 'rule.with.fixes', + level: 'warning', + message: { text: 'Fix available' }, + fixes: [{}], + locations: [{ physicalLocation: { artifactLocation: { uri: 'src/a.ts' }, region: { startLine: 1, startColumn: 1 } } }], + }, + { + ruleId: 'rule.fixable.flag', + level: 'warning', + message: { text: 'Property fixable' }, + properties: { fixable: true }, + locations: [{ physicalLocation: { artifactLocation: { uri: 'src/b.ts' }, region: { startLine: 2, startColumn: 1 } } }], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const runner = new QltyRunner('/usr/bin/qlty'); + const result = await runner.scan(['src/a.ts', 'src/b.ts'], '/repo', 10_000); + + expect(result.findings).toHaveLength(2); + expect(result.findings[0].fixable).toBe(true); + expect(result.findings[1].fixable).toBe(true); + }); + + it('runs qlty install and retries check when initial check has zero findings', async () => { + mockedSpawnAsync + .mockResolvedValueOnce({ + stdout: JSON.stringify({ runs: [{ results: [] }] }), + stderr: '', + exitCode: 0, + timedOut: false, + }) + .mockResolvedValueOnce({ + stdout: 'installed', + stderr: '', + exitCode: 0, + timedOut: false, + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + runs: [ + { + results: [ + { + ruleId: 'qlty/style/rule', + level: 'note', + message: { text: 'format issue' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 1, startColumn: 1 }, + }, + }, + ], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const runner = new QltyRunner('/usr/bin/qlty'); + const result = await runner.scan(['src/app.ts'], '/repo-bootstrap', 30_000); + + expect(mockedSpawnAsync).toHaveBeenCalledTimes(3); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith( + 1, + '/usr/bin/qlty', + ['check', '--sarif', '--no-progress', 'src/app.ts'], + { cwd: '/repo-bootstrap', timeout: 30_000 } + ); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith( + 2, + '/usr/bin/qlty', + ['install'], + { cwd: '/repo-bootstrap', timeout: 30_000 } + ); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith( + 3, + '/usr/bin/qlty', + ['check', '--sarif', '--no-progress', 'src/app.ts'], + { cwd: '/repo-bootstrap', timeout: 30_000 } + ); + expect(result.findings).toHaveLength(1); + expect(result.findings[0]).toMatchObject({ + runnerId: 'qlty-check', + ruleId: 'qlty/style/rule', + }); + }); + + it('runs qlty install and retries check when initial check is non-zero with zero findings', async () => { + mockedSpawnAsync + .mockResolvedValueOnce({ + stdout: JSON.stringify({ runs: [{ results: [] }] }), + stderr: 'plugin setup incomplete', + exitCode: 2, + timedOut: false, + }) + .mockResolvedValueOnce({ + stdout: 'installed', + stderr: '', + exitCode: 0, + timedOut: false, + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + runs: [ + { + results: [ + { + ruleId: 'qlty/security/rule', + level: 'warning', + message: { text: 'security issue' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 12, startColumn: 3 }, + }, + }, + ], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 1, + timedOut: false, + }); + + const runner = new QltyRunner('/usr/bin/qlty'); + const result = await runner.scan(['src/app.ts'], '/repo-bootstrap-nonzero', 30_000); + + expect(mockedSpawnAsync).toHaveBeenCalledTimes(3); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith( + 1, + '/usr/bin/qlty', + ['check', '--sarif', '--no-progress', 'src/app.ts'], + { cwd: '/repo-bootstrap-nonzero', timeout: 30_000 } + ); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith( + 2, + '/usr/bin/qlty', + ['install'], + { cwd: '/repo-bootstrap-nonzero', timeout: 30_000 } + ); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith( + 3, + '/usr/bin/qlty', + ['check', '--sarif', '--no-progress', 'src/app.ts'], + { cwd: '/repo-bootstrap-nonzero', timeout: 30_000 } + ); + expect(result.findings).toHaveLength(1); + expect(result.findings[0]).toMatchObject({ + runnerId: 'qlty-check', + ruleId: 'qlty/security/rule', + }); + }); +}); diff --git a/src/services/static-analysis/__tests__/qlty-smells-runner.test.ts b/src/services/static-analysis/__tests__/qlty-smells-runner.test.ts new file mode 100644 index 0000000..3439245 --- /dev/null +++ b/src/services/static-analysis/__tests__/qlty-smells-runner.test.ts @@ -0,0 +1,265 @@ +import fs, { PathLike } from 'fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LoggerService } from '../../../common/logger'; +import { SilentTransport } from '../../../common/logger/transports/silent.transport'; +import { QltySmellsRunner } from '../qlty-smells-runner'; +import { spawnAsync } from '../spawn-helper'; + +vi.mock('../spawn-helper', () => ({ + spawnAsync: vi.fn(), +})); + +const mockedSpawnAsync = vi.mocked(spawnAsync); +let existsSyncSpy: ReturnType; + +describe('QltySmellsRunner', () => { + beforeEach(() => { + mockedSpawnAsync.mockReset(); + existsSyncSpy = vi.spyOn(fs, 'existsSync').mockReturnValue(false); + LoggerService.setTransport(new SilentTransport()); + LoggerService.setLevel('debug'); + }); + + afterEach(() => { + existsSyncSpy.mockRestore(); + LoggerService.resetForTests(); + }); + + it('executes qlty smells with expected defaults', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ + runs: [ + { + results: [ + { + ruleId: 'qlty:function-complexity', + level: 'warning', + message: { text: 'Function with high complexity (count = 20): fn' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 12, startColumn: 2 }, + }, + }, + ], + taxa: [{ id: 'structure', name: 'structure' }], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 1, + timedOut: false, + }); + + const runner = new QltySmellsRunner('/usr/bin/qlty'); + const result = await runner.scan(['src/app.ts'], '/repo', 20_000); + + expect(mockedSpawnAsync).toHaveBeenCalledWith( + '/usr/bin/qlty', + ['smells', '--sarif', '--quiet', '--no-snippets', 'src/app.ts'], + { cwd: '/repo', timeout: 20_000 } + ); + expect(result.findings).toHaveLength(1); + expect(result.findings[0]).toMatchObject({ + source: 'qlty', + runnerId: 'qlty-smells', + category: 'complexity', + severity: 'warning', + }); + }); + + it('adds --no-duplication when includeDuplication is false', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ runs: [{ results: [] }] }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const runner = new QltySmellsRunner('/usr/bin/qlty', { includeDuplication: false }); + await runner.scan(['src/app.ts'], '/repo', 10_000); + + expect(mockedSpawnAsync).toHaveBeenCalledWith( + '/usr/bin/qlty', + ['smells', '--sarif', '--quiet', '--no-snippets', '--no-duplication', 'src/app.ts'], + { cwd: '/repo', timeout: 10_000 } + ); + }); + + it('returns empty findings when smells times out', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: '', + stderr: 'timed out', + exitCode: null, + timedOut: true, + }); + + const runner = new QltySmellsRunner('/usr/bin/qlty'); + const result = await runner.scan(['src/app.ts'], '/repo', 500); + + expect(result.findings).toEqual([]); + expect(result.rawOutput.timedOut).toBe(true); + }); + + it('runs qlty init and retries smells once when init is required', async () => { + mockedSpawnAsync + .mockResolvedValueOnce({ + stdout: '', + stderr: 'Project not initialized. Please run qlty init before qlty smells.', + exitCode: 2, + timedOut: false, + }) + .mockResolvedValueOnce({ + stdout: 'Initialized', + stderr: '', + exitCode: 0, + timedOut: false, + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + runs: [ + { + results: [ + { + ruleId: 'qlty:file-complexity', + level: 'warning', + message: { text: 'High total complexity (count = 70)' }, + taxa: [{ id: 'structure', name: 'structure' }], + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 1, startColumn: 1 }, + }, + }, + ], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 1, + timedOut: false, + }); + + const runner = new QltySmellsRunner('/usr/bin/qlty'); + const result = await runner.scan(['src/app.ts'], '/repo', 30_000); + + expect(mockedSpawnAsync).toHaveBeenCalledTimes(3); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith( + 1, + '/usr/bin/qlty', + ['smells', '--sarif', '--quiet', '--no-snippets', 'src/app.ts'], + { cwd: '/repo', timeout: 30_000 } + ); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith( + 2, + '/usr/bin/qlty', + ['init'], + { cwd: '/repo', timeout: 30_000 } + ); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith( + 3, + '/usr/bin/qlty', + ['smells', '--sarif', '--quiet', '--no-snippets', 'src/app.ts'], + { cwd: '/repo', timeout: 30_000 } + ); + expect(result.findings).toHaveLength(1); + }); + + it('returns empty findings when qlty init fails and does not throw', async () => { + mockedSpawnAsync + .mockResolvedValueOnce({ + stdout: '', + stderr: 'Project is not initialized. Run qlty init', + exitCode: 2, + timedOut: false, + }) + .mockResolvedValueOnce({ + stdout: '', + stderr: 'init failed', + exitCode: 1, + timedOut: false, + }); + + const runner = new QltySmellsRunner('/usr/bin/qlty'); + const result = await runner.scan(['src/app.ts'], '/repo', 20_000); + + expect(mockedSpawnAsync).toHaveBeenCalledTimes(2); + expect(result.findings).toEqual([]); + expect(result.rawOutput.exitCode).toBe(2); + }); + + it('accepts qlty init with non-zero exit when config file was created', async () => { + existsSyncSpy.mockImplementation((targetPath: PathLike) => + String(targetPath).endsWith('/.qlty/qlty.toml') + ); + + mockedSpawnAsync + .mockResolvedValueOnce({ + stdout: '', + stderr: 'No qlty config file found. Try running `qlty init`', + exitCode: 99, + timedOut: false, + }) + .mockResolvedValueOnce({ + stdout: 'initialized with warning', + stderr: 'IO error: not a terminal', + exitCode: 99, + timedOut: false, + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + runs: [ + { + results: [ + { + ruleId: 'qlty:file-complexity', + level: 'warning', + message: { text: 'High total complexity (count = 70)' }, + taxa: [{ id: 'structure', name: 'structure' }], + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 1, startColumn: 1 }, + }, + }, + ], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 1, + timedOut: false, + }); + + const runner = new QltySmellsRunner('/usr/bin/qlty'); + const result = await runner.scan(['src/app.ts'], '/repo', 20_000); + + expect(mockedSpawnAsync).toHaveBeenCalledTimes(3); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith( + 1, + '/usr/bin/qlty', + ['smells', '--sarif', '--quiet', '--no-snippets', 'src/app.ts'], + { cwd: '/repo', timeout: 20_000 } + ); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith(2, '/usr/bin/qlty', ['init'], { + cwd: '/repo', + timeout: 20_000, + }); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith( + 3, + '/usr/bin/qlty', + ['smells', '--sarif', '--quiet', '--no-snippets', 'src/app.ts'], + { cwd: '/repo', timeout: 20_000 } + ); + expect(result.findings).toHaveLength(1); + }); +}); diff --git a/src/services/static-analysis/__tests__/semgrep-runner.test.ts b/src/services/static-analysis/__tests__/semgrep-runner.test.ts new file mode 100644 index 0000000..062688c --- /dev/null +++ b/src/services/static-analysis/__tests__/semgrep-runner.test.ts @@ -0,0 +1,292 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LoggerService } from '../../../common/logger'; +import { SilentTransport } from '../../../common/logger/transports/silent.transport'; +import { SemgrepRunner } from '../semgrep-runner'; +import { spawnAsync } from '../spawn-helper'; + +vi.mock('../spawn-helper', () => ({ + spawnAsync: vi.fn(), +})); + +const mockedSpawnAsync = vi.mocked(spawnAsync); + +describe('SemgrepRunner', () => { + beforeEach(() => { + mockedSpawnAsync.mockReset(); + LoggerService.setTransport(new SilentTransport()); + LoggerService.setLevel('debug'); + }); + + afterEach(() => { + LoggerService.resetForTests(); + }); + + it('maps valid Semgrep JSON output to unified findings', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ + results: [ + { + check_id: 'javascript.express.security.audit.xss.mustache-escape', + path: 'src/routes/user.ts', + start: { line: 45, col: 10 }, + extra: { + message: 'Potential XSS vulnerability', + severity: 'WARNING', + metadata: { + category: 'security', + cwe: ['CWE-79'], + owasp: ['A03:2021'], + }, + }, + }, + ], + }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const runner = new SemgrepRunner('/usr/bin/semgrep', ['auto', 'p/security-audit']); + const result = await runner.scan(['src/routes/user.ts'], '/repo', 60_000); + + expect(mockedSpawnAsync).toHaveBeenCalledWith( + '/usr/bin/semgrep', + [ + 'scan', + '--json', + '--quiet', + '--oss-only', + '--config', + 'auto', + '--config', + 'p/security-audit', + 'src/routes/user.ts', + ], + { cwd: '/repo', timeout: 60_000 } + ); + + expect(result.findings).toHaveLength(1); + expect(result.findings[0]).toEqual({ + source: 'semgrep', + runnerId: 'semgrep', + file: 'src/routes/user.ts', + line: 45, + column: 10, + message: 'Potential XSS vulnerability', + severity: 'warning', + category: 'security', + ruleId: 'javascript.express.security.audit.xss.mustache-escape', + cwe: ['CWE-79'], + owasp: ['A03:2021'], + }); + expect(result.rawOutput.exitCode).toBe(0); + }); + + it('parses findings when Semgrep exits with code 1', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ + results: [ + { + check_id: 'generic.security.rule', + path: 'src/auth.ts', + start: { line: 10, col: 3 }, + extra: { message: 'Issue', severity: 'ERROR', metadata: {} }, + }, + ], + }), + stderr: '', + exitCode: 1, + timedOut: false, + }); + + const runner = new SemgrepRunner('/usr/bin/semgrep', ['auto']); + const result = await runner.scan(['src/auth.ts'], '/repo', 30_000); + + expect(result.findings).toHaveLength(1); + expect(result.findings[0].severity).toBe('error'); + expect(result.rawOutput.exitCode).toBe(1); + }); + + it('returns empty findings for empty Semgrep results', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ results: [] }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const runner = new SemgrepRunner('/usr/bin/semgrep', ['auto']); + const result = await runner.scan(['src/a.ts'], '/repo', 5_000); + expect(result.findings).toEqual([]); + }); + + it('returns empty findings on malformed JSON without throwing', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: '{invalid-json', + stderr: '', + exitCode: 2, + timedOut: false, + }); + + const runner = new SemgrepRunner('/usr/bin/semgrep', ['auto']); + await expect(runner.scan(['src/a.ts'], '/repo', 5_000)).resolves.toMatchObject({ + findings: [], + rawOutput: { + exitCode: 2, + }, + }); + }); + + it('returns empty findings when process timed out', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ + results: [ + { + check_id: 'rule', + path: 'src/a.ts', + start: { line: 1, col: 1 }, + extra: { message: 'x', severity: 'WARNING' }, + }, + ], + }), + stderr: 'timeout', + exitCode: null, + timedOut: true, + }); + + const runner = new SemgrepRunner('/usr/bin/semgrep', ['auto']); + const result = await runner.scan(['src/a.ts'], '/repo', 1_000); + + expect(result.findings).toEqual([]); + expect(result.rawOutput.timedOut).toBe(true); + }); + + it('maps severities and category derivation as expected', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ + results: [ + { + check_id: 'my.security.audit.rule', + path: 'src/one.ts', + start: { line: 1, col: 1 }, + extra: { message: 'a', severity: 'ERROR', metadata: {} }, + }, + { + check_id: 'my.correctness.rule', + path: 'src/two.ts', + start: { line: 2, col: 2 }, + extra: { message: 'b', severity: 'INFO', metadata: {} }, + }, + { + check_id: 'my.style.rule', + path: 'src/three.ts', + start: { line: 3, col: 3 }, + extra: { + message: 'c', + severity: 'UNKNOWN', + metadata: { category: 'duplication' }, + }, + }, + { + check_id: 'my.unmapped.rule', + path: 'src/four.ts', + start: { line: 4, col: 4 }, + extra: { message: 'd', severity: 'WARNING', metadata: {} }, + }, + ], + }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const runner = new SemgrepRunner('/usr/bin/semgrep', ['auto']); + const result = await runner.scan( + ['src/one.ts', 'src/two.ts', 'src/three.ts', 'src/four.ts'], + '/repo', + 5_000 + ); + + expect(result.findings.map((f) => f.severity)).toEqual([ + 'error', + 'info', + 'warning', + 'warning', + ]); + expect(result.findings.map((f) => f.category)).toEqual([ + 'security', + 'bug', + 'duplication', + 'security', + ]); + }); + + it('returns empty findings when output shape is unexpected', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ foo: 'bar' }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const runner = new SemgrepRunner('/usr/bin/semgrep', ['auto']); + const result = await runner.scan(['src/x.ts'], '/repo', 5_000); + expect(result.findings).toEqual([]); + }); + + it('short-circuits when no files are provided', async () => { + const runner = new SemgrepRunner('/usr/bin/semgrep', ['auto']); + const result = await runner.scan([], '/repo', 5_000); + + expect(mockedSpawnAsync).not.toHaveBeenCalled(); + expect(result).toEqual({ + findings: [], + rawOutput: { + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }, + }); + }); + + it('uses fallback defaults for incomplete finding fields', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ + results: [ + { + check_id: '', + path: '', + start: {}, + extra: { + message: '', + severity: '', + metadata: { + cwe: ['CWE-89', 99], + owasp: ['A01:2021', null], + }, + }, + }, + ], + }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const runner = new SemgrepRunner('/usr/bin/semgrep', ['auto']); + const result = await runner.scan(['src/fallback.ts'], '/repo', 5_000); + + expect(result.findings).toHaveLength(1); + expect(result.findings[0]).toMatchObject({ + ruleId: 'unknown.semgrep.rule', + file: 'unknown.file', + line: 1, + column: 1, + message: 'Semgrep finding', + severity: 'warning', + cwe: ['CWE-89'], + owasp: ['A01:2021'], + }); + }); +}); diff --git a/src/services/static-analysis/__tests__/spawn-helper.test.ts b/src/services/static-analysis/__tests__/spawn-helper.test.ts new file mode 100644 index 0000000..50b654c --- /dev/null +++ b/src/services/static-analysis/__tests__/spawn-helper.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from 'vitest'; +import { spawnAsync } from '../spawn-helper'; +import path from 'path'; + +describe('spawnAsync', () => { + it('should capture stdout from a successful command', async () => { + const result = await spawnAsync(process.execPath, ['-e', 'console.log("hello world")']); + expect(result.stdout.trim()).toBe('hello world'); + expect(result.exitCode).toBe(0); + expect(result.timedOut).toBe(false); + }); + + it('should capture stderr', async () => { + const result = await spawnAsync(process.execPath, ['-e', 'console.error("error")']); + expect(result.stderr.trim()).toBe('error'); + expect(result.exitCode).toBe(0); + }); + + it('should return non-zero exit code without rejecting', async () => { + const result = await spawnAsync(process.execPath, ['-e', 'process.exit(42)']); + expect(result.exitCode).toBe(42); + expect(result.timedOut).toBe(false); + }); + + it('should resolve with error info for non-existent command', async () => { + const result = await spawnAsync('nonexistent-command-xyz', []); + expect(result.exitCode).toBeNull(); + expect(result.stderr).toBeTruthy(); + expect(result.timedOut).toBe(false); + }); + + it('should kill process on timeout', async () => { + const result = await spawnAsync(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], { + timeout: 200, + }); + expect(result.timedOut).toBe(true); + expect(result.exitCode).toBeNull(); + }, 10_000); + + it('should respect cwd option', async () => { + const cwd = process.platform === 'win32' ? process.cwd() : '/tmp'; + const result = await spawnAsync(process.execPath, ['-e', 'console.log(process.cwd())'], { cwd }); + const actual = result.stdout.trim(); + const matchesRealPath = + actual === cwd || + path.resolve(actual) === path.resolve(cwd) || + (process.platform !== 'win32' && path.resolve(actual) === path.resolve('/private/tmp')); + expect(matchesRealPath).toBe(true); + expect(result.exitCode).toBe(0); + }); + + it('should handle large output without crashing', async () => { + const result = await spawnAsync(process.execPath, [ + '-e', + 'for (let i = 1; i <= 1000; i++) console.log(`line ${i}: padding data for buffer test`);', + ]); + expect(result.stdout.length).toBeGreaterThan(10000); + expect(result.exitCode).toBe(0); + }); + + it('should truncate output at maxBuffer', async () => { + const result = await spawnAsync(process.execPath, ['-e', 'for (let i = 1; i <= 10000; i++) console.log(`line ${i}`);'], { + maxBuffer: 1024, + }); + expect(result.stdout.length).toBeLessThanOrEqual(1024); + expect(result.exitCode).toBe(0); + }); +}); diff --git a/src/services/static-analysis/__tests__/static-analysis.service.test.ts b/src/services/static-analysis/__tests__/static-analysis.service.test.ts new file mode 100644 index 0000000..c45c338 --- /dev/null +++ b/src/services/static-analysis/__tests__/static-analysis.service.test.ts @@ -0,0 +1,1027 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LoggerService } from '../../../common/logger'; +import { SilentTransport } from '../../../common/logger/transports/silent.transport'; +import { IToolRunner, StaticAnalysisConfig } from '../types'; +import { spawnAsync } from '../spawn-helper'; +import { ToolInstaller } from '../tool-installer'; +import { StaticAnalysisService } from '../static-analysis.service'; + +vi.mock('../spawn-helper', () => ({ + spawnAsync: vi.fn(), +})); + +const mockedSpawnAsync = vi.mocked(spawnAsync); + +function createConfig(overrides: Partial = {}): StaticAnalysisConfig { + return { + enabled: true, + autoInstall: false, + autoInstallSystemDeps: false, + excludedPaths: [], + semgrep: { + enabled: true, + rules: ['auto'], + timeout: 30_000, + }, + qlty: { + enabled: false, + timeout: 30_000, + smells: { + enabled: true, + timeout: 30_000, + includeDuplication: true, + }, + }, + maxFindings: 500, + minSeverity: 'warning', + ...overrides, + }; +} + +function createChangedLineDiff(): string { + return `diff --git a/src/app.ts b/src/app.ts +index 1234567..89abcde 100644 +--- a/src/app.ts ++++ b/src/app.ts +@@ -10,2 +10,3 @@ + const old = 1; +-const value = old; ++const value = old + 1; ++const added = true; + return value; +`; +} + +describe('StaticAnalysisService', () => { + let repoRoot: string; + + beforeEach(() => { + mockedSpawnAsync.mockReset(); + ToolInstaller.resetForTests(); + LoggerService.setTransport(new SilentTransport()); + LoggerService.setLevel('debug'); + + repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'codewave-static-analysis-')); + fs.mkdirSync(path.join(repoRoot, 'src'), { recursive: true }); + fs.writeFileSync(path.join(repoRoot, 'src', 'app.ts'), 'export const value = 1;\n'); + fs.writeFileSync(path.join(repoRoot, 'src', 'app.test.ts'), 'describe("x", () => {});\n'); + + vi.spyOn(ToolInstaller, 'ensureAvailable').mockResolvedValue({ + semgrep: { + available: true, + executable: '/usr/bin/semgrep', + installedNow: false, + }, + qlty: { + available: false, + executable: null, + installedNow: false, + }, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + ToolInstaller.resetForTests(); + LoggerService.resetForTests(); + if (fs.existsSync(repoRoot)) { + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + + it('runs semgrep during evaluation when semgrep is enabled and qlty is disabled', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ + results: [ + { + check_id: 'semgrep/rule', + path: 'src/app.ts', + start: { line: 1, col: 1 }, + extra: { + message: 'semgrep finding', + severity: 'WARNING', + metadata: { category: 'security' }, + }, + }, + ], + }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const result = await StaticAnalysisService.analyze( + ['src/app.ts'], + repoRoot, + createConfig() + ); + + expect(mockedSpawnAsync).toHaveBeenCalledTimes(1); + expect(mockedSpawnAsync).toHaveBeenCalledWith( + '/usr/bin/semgrep', + ['scan', '--json', '--quiet', '--oss-only', '--config', 'auto', 'src/app.ts'], + { cwd: repoRoot, timeout: 30_000 } + ); + expect(result.summary.counts.total).toBe(1); + expect(result.summary.available.semgrep).toBe(true); + expect(result.summary.available.qlty).toBe(false); + expect(result.summary.timing.toolAvailabilityMs).toBeTypeOf('number'); + expect(result.summary.timing.scopeResolutionMs).toBeTypeOf('number'); + expect(result.summary.timing.postProcessMs).toBeTypeOf('number'); + expect(result.summary.timing.overheadMs).toBeTypeOf('number'); + }); + + it('always excludes test files regardless of configured excludedPaths', async () => { + const result = await StaticAnalysisService.analyze( + ['src/app.ts', 'src/app.test.ts'], + repoRoot, + createConfig({ + excludedPaths: [], + semgrep: { + enabled: false, + rules: ['auto'], + timeout: 30_000, + }, + }) + ); + + expect(mockedSpawnAsync).not.toHaveBeenCalled(); + expect(result.summary.skippedFiles.excluded).toContain('src/app.test.ts'); + }); + + it('ignores config.autoInstall at runtime and emits a deprecation warning', async () => { + vi.mocked(ToolInstaller.ensureAvailable).mockResolvedValue({ + semgrep: { + available: true, + executable: '/usr/bin/semgrep', + installedNow: false, + }, + qlty: { + available: false, + executable: null, + installedNow: false, + }, + }); + + const result = await StaticAnalysisService.analyze( + ['src/app.ts'], + repoRoot, + createConfig({ + autoInstall: true, + semgrep: { + enabled: false, + rules: ['auto'], + timeout: 30_000, + }, + qlty: { + enabled: false, + timeout: 30_000, + smells: { + enabled: false, + timeout: 30_000, + includeDuplication: true, + }, + }, + }) + ); + + expect(ToolInstaller.ensureAvailable).toHaveBeenCalledWith(false, false); + expect(result.summary.warnings).toContainEqual( + expect.stringContaining( + 'staticAnalysis.autoInstall=true is deprecated and ignored during evaluation.' + ) + ); + }); + + it('returns empty findings when scope resolution yields no scan files', async () => { + const result = await StaticAnalysisService.analyze( + ['src/missing.ts'], + repoRoot, + createConfig() + ); + + expect(mockedSpawnAsync).not.toHaveBeenCalled(); + expect(result.summary.counts.total).toBe(0); + expect(result.summary.warnings).toContain( + 'No eligible commit files for static analysis after scope resolution.' + ); + expect(result.summary.skippedFiles.missing).toContain('src/missing.ts'); + }); + + it('emits missing tool warning and setup hint when static analysis is enabled but tools are unavailable', async () => { + vi.mocked(ToolInstaller.ensureAvailable).mockResolvedValue({ + semgrep: { + available: false, + executable: null, + installedNow: false, + error: 'semgrep not available', + }, + qlty: { + available: false, + executable: null, + installedNow: false, + error: 'qlty not available', + }, + }); + + const result = await StaticAnalysisService.analyze( + ['src/app.ts'], + repoRoot, + createConfig({ + semgrep: { + enabled: true, + rules: ['auto'], + timeout: 30_000, + }, + qlty: { + enabled: true, + timeout: 30_000, + smells: { + enabled: false, + timeout: 30_000, + includeDuplication: true, + }, + }, + }) + ); + + expect(mockedSpawnAsync).not.toHaveBeenCalled(); + expect(result.summary.warnings).toContain( + 'Static analysis requested, but tools are not installed. Continuing without unavailable analyzers.' + ); + expect(result.summary.warnings).toContain( + "Run 'codewave config --init' and choose 'Yes' for static analyzer installation to enable Semgrep/Qlty." + ); + }); + + it('applies min severity filter and max findings cap', async () => { + vi.mocked(ToolInstaller.ensureAvailable).mockResolvedValue({ + semgrep: { + available: true, + executable: '/usr/bin/semgrep', + installedNow: false, + }, + qlty: { + available: true, + executable: '/usr/bin/qlty', + installedNow: false, + }, + }); + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ + runs: [ + { + results: [ + { + ruleId: 'qlty/info', + level: 'note', + message: { text: 'info' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 1, startColumn: 1 }, + }, + }, + ], + }, + { + ruleId: 'qlty/error/one', + level: 'error', + message: { text: 'err1' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 2, startColumn: 1 }, + }, + }, + ], + }, + { + ruleId: 'qlty/error/two', + level: 'error', + message: { text: 'err2' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 3, startColumn: 1 }, + }, + }, + ], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const result = await StaticAnalysisService.analyze( + ['src/app.ts'], + repoRoot, + createConfig({ + semgrep: { + enabled: false, + rules: ['auto'], + timeout: 30_000, + }, + qlty: { + enabled: true, + timeout: 30_000, + smells: { + enabled: false, + timeout: 30_000, + includeDuplication: true, + }, + }, + minSeverity: 'warning', + maxFindings: 1, + }) + ); + + expect(result.summary.totalBeforeCap).toBe(2); + expect(result.summary.findings).toHaveLength(1); + expect(result.summary.findings[0].severity).toBe('error'); + expect(result.summary.truncated).toBe(true); + }); + + it('runs qlty check when enabled and available with smells disabled', async () => { + vi.mocked(ToolInstaller.ensureAvailable).mockResolvedValue({ + semgrep: { + available: true, + executable: '/usr/bin/semgrep', + installedNow: false, + }, + qlty: { + available: true, + executable: '/usr/bin/qlty', + installedNow: false, + }, + }); + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ + runs: [ + { + results: [ + { + ruleId: 'qlty/complexity/high', + level: 'warning', + message: { text: 'Cyclomatic complexity too high' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 10, startColumn: 2 }, + }, + }, + ], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const result = await StaticAnalysisService.analyze( + ['src/app.ts'], + repoRoot, + createConfig({ + semgrep: { + enabled: false, + rules: ['auto'], + timeout: 30_000, + }, + qlty: { + enabled: true, + timeout: 30_000, + smells: { + enabled: false, + timeout: 30_000, + includeDuplication: true, + }, + }, + }) + ); + + expect(mockedSpawnAsync).toHaveBeenCalledTimes(1); + expect(mockedSpawnAsync).toHaveBeenCalledWith( + '/usr/bin/qlty', + ['check', '--sarif', '--no-progress', 'src/app.ts'], + { cwd: repoRoot, timeout: 30_000 } + ); + expect(result.summary.available.semgrep).toBe(false); + expect(result.summary.available.qlty).toBe(true); + expect(result.summary.timing.qltyMs).toBeTypeOf('number'); + expect(result.summary.findings[0]).toMatchObject({ + source: 'qlty', + runnerId: 'qlty-check', + category: 'complexity', + severity: 'warning', + }); + expect(result.rawOutputs.qlty?.exitCode).toBe(0); + expect(result.rawOutputs.qltySmells).toBeUndefined(); + expect(result.summary.counts.byRunner['qlty-check']).toBe(1); + expect(result.summary.counts.byRunner['qlty-smells']).toBe(0); + }); + + it('runs qlty check and qlty smells when both are enabled and available', async () => { + vi.mocked(ToolInstaller.ensureAvailable).mockResolvedValue({ + semgrep: { + available: true, + executable: '/usr/bin/semgrep', + installedNow: false, + }, + qlty: { + available: true, + executable: '/usr/bin/qlty', + installedNow: false, + }, + }); + + mockedSpawnAsync + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + runs: [ + { + results: [ + { + ruleId: 'prettier:fmt', + level: 'note', + message: { text: 'Incorrect formatting' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 1, startColumn: 1 }, + }, + }, + ], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 1, + timedOut: false, + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + runs: [ + { + results: [ + { + ruleId: 'qlty:function-complexity', + level: 'warning', + message: { text: 'Function with high complexity (count = 24): analyze' }, + taxa: [{ id: 'structure', name: 'structure' }], + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 20, startColumn: 1 }, + }, + }, + ], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 1, + timedOut: false, + }); + + const result = await StaticAnalysisService.analyze( + ['src/app.ts'], + repoRoot, + createConfig({ + semgrep: { + enabled: false, + rules: ['auto'], + timeout: 30_000, + }, + qlty: { + enabled: true, + timeout: 30_000, + smells: { + enabled: true, + timeout: 12_000, + includeDuplication: false, + }, + }, + minSeverity: 'info', + }) + ); + + expect(mockedSpawnAsync).toHaveBeenCalledTimes(2); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith( + 1, + '/usr/bin/qlty', + ['check', '--sarif', '--no-progress', 'src/app.ts'], + { cwd: repoRoot, timeout: 30_000 } + ); + expect(mockedSpawnAsync).toHaveBeenNthCalledWith( + 2, + '/usr/bin/qlty', + ['smells', '--sarif', '--quiet', '--no-snippets', '--no-duplication', 'src/app.ts'], + { cwd: repoRoot, timeout: 12_000 } + ); + + expect(result.summary.available.semgrep).toBe(false); + expect(result.summary.available.qlty).toBe(true); + expect(result.summary.timing.qltyMs).toBeTypeOf('number'); + expect(result.summary.findings.some((finding) => finding.category === 'complexity')).toBe(true); + expect(result.summary.findings.some((finding) => finding.runnerId === 'qlty-check')).toBe(true); + expect(result.summary.findings.some((finding) => finding.runnerId === 'qlty-smells')).toBe(true); + expect(result.summary.counts.byRunner['qlty-check']).toBeGreaterThan(0); + expect(result.summary.counts.byRunner['qlty-smells']).toBeGreaterThan(0); + expect(result.rawOutputs.qlty?.exitCode).toBe(1); + expect(result.rawOutputs.qltySmells?.exitCode).toBe(1); + }); + + it('runs semgrep in parallel with a serialized qlty pipeline', async () => { + vi.mocked(ToolInstaller.ensureAvailable).mockResolvedValue({ + semgrep: { + available: true, + executable: '/usr/bin/semgrep', + installedNow: false, + }, + qlty: { + available: true, + executable: '/usr/bin/qlty', + installedNow: false, + }, + }); + + let resolveSemgrep: + | ((value: { stdout: string; stderr: string; exitCode: number | null; timedOut: boolean }) => void) + | null = null; + + mockedSpawnAsync.mockImplementation((command, args) => { + if (command === '/usr/bin/semgrep') { + return new Promise((resolve) => { + resolveSemgrep = resolve; + }); + } + + if (command === '/usr/bin/qlty' && args[0] === 'check') { + return Promise.resolve({ + stdout: JSON.stringify({ + runs: [ + { + results: [ + { + ruleId: 'qlty/check/parallel', + level: 'warning', + message: { text: 'qlty check finding' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 11, startColumn: 1 }, + }, + }, + ], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + } + + if (command === '/usr/bin/qlty' && args[0] === 'smells') { + return Promise.resolve({ + stdout: JSON.stringify({ + runs: [ + { + results: [ + { + ruleId: 'qlty/smells/parallel', + level: 'warning', + message: { text: 'qlty smells finding' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 12, startColumn: 1 }, + }, + }, + ], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + } + + return Promise.resolve({ + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }); + }); + + const analyzePromise = StaticAnalysisService.analyze( + ['src/app.ts'], + repoRoot, + createConfig({ + semgrep: { + enabled: true, + rules: ['auto'], + timeout: 30_000, + }, + qlty: { + enabled: true, + timeout: 30_000, + smells: { + enabled: true, + timeout: 30_000, + includeDuplication: true, + }, + }, + minSeverity: 'info', + }) + ); + + for (let i = 0; i < 20; i += 1) { + const qltyCallCount = mockedSpawnAsync.mock.calls.filter((call) => call[0] === '/usr/bin/qlty') + .length; + if (qltyCallCount >= 2) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + const semgrepCalls = mockedSpawnAsync.mock.calls.filter((call) => call[0] === '/usr/bin/semgrep'); + const qltyCalls = mockedSpawnAsync.mock.calls.filter((call) => call[0] === '/usr/bin/qlty'); + + expect(semgrepCalls).toHaveLength(1); + expect(qltyCalls).toHaveLength(2); + expect(qltyCalls[0]?.[1]).toEqual(['check', '--sarif', '--no-progress', 'src/app.ts']); + expect(qltyCalls[1]?.[1]).toEqual([ + 'smells', + '--sarif', + '--quiet', + '--no-snippets', + 'src/app.ts', + ]); + expect(resolveSemgrep).not.toBeNull(); + + resolveSemgrep!({ + stdout: JSON.stringify({ + results: [ + { + check_id: 'semgrep/parallel', + path: 'src/app.ts', + start: { line: 13, col: 1 }, + extra: { + message: 'semgrep finding', + severity: 'WARNING', + metadata: { category: 'security' }, + }, + }, + ], + }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const result = await analyzePromise; + + expect(result.summary.findings.some((finding) => finding.runnerId === 'semgrep')).toBe(true); + expect(result.summary.findings.some((finding) => finding.runnerId === 'qlty-check')).toBe(true); + expect(result.summary.findings.some((finding) => finding.runnerId === 'qlty-smells')).toBe(true); + }); + + it('does not run qlty when qlty is enabled but unavailable', async () => { + vi.mocked(ToolInstaller.ensureAvailable).mockResolvedValue({ + semgrep: { + available: true, + executable: '/usr/bin/semgrep', + installedNow: false, + }, + qlty: { + available: false, + executable: null, + installedNow: false, + }, + }); + + const result = await StaticAnalysisService.analyze( + ['src/app.ts'], + repoRoot, + createConfig({ + semgrep: { + enabled: false, + rules: ['auto'], + timeout: 30_000, + }, + qlty: { + enabled: true, + timeout: 30_000, + smells: { + enabled: true, + timeout: 30_000, + includeDuplication: true, + }, + }, + }) + ); + + expect(mockedSpawnAsync).not.toHaveBeenCalled(); + expect(result.summary.available.qlty).toBe(false); + expect(result.summary.warnings).not.toContain('Qlty runner is not implemented yet; qlty scan skipped.'); + }); + + it('surfaces semgrep availability error when semgrep is enabled but unavailable', async () => { + vi.mocked(ToolInstaller.ensureAvailable).mockResolvedValue({ + semgrep: { + available: false, + executable: null, + installedNow: false, + error: 'semgrep failed to install', + }, + qlty: { + available: false, + executable: null, + installedNow: false, + }, + }); + + const result = await StaticAnalysisService.analyze( + ['src/app.ts'], + repoRoot, + createConfig() + ); + + expect(mockedSpawnAsync).not.toHaveBeenCalled(); + expect(result.summary.available.semgrep).toBe(false); + expect(result.summary.warnings).toContain('semgrep failed to install'); + }); + + it('keeps only changed-line findings for qlty check and qlty smells', async () => { + vi.mocked(ToolInstaller.ensureAvailable).mockResolvedValue({ + semgrep: { + available: true, + executable: '/usr/bin/semgrep', + installedNow: false, + }, + qlty: { + available: true, + executable: '/usr/bin/qlty', + installedNow: false, + }, + }); + + mockedSpawnAsync + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + runs: [ + { + results: [ + { + ruleId: 'qlty/check/changed-line', + level: 'warning', + message: { text: 'changed line finding' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 11, startColumn: 1 }, + }, + }, + ], + }, + { + ruleId: 'qlty/check/unchanged-line', + level: 'warning', + message: { text: 'unchanged line finding' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 14, startColumn: 1 }, + }, + }, + ], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 1, + timedOut: false, + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + runs: [ + { + results: [ + { + ruleId: 'qlty/smells/changed-line', + level: 'warning', + message: { text: 'changed smell' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 12, startColumn: 1 }, + }, + }, + ], + }, + { + ruleId: 'qlty/smells/file-level', + level: 'warning', + message: { text: 'file level smell' }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: 'src/app.ts' }, + region: { startLine: 1, startColumn: 1 }, + }, + }, + ], + }, + ], + }, + ], + }), + stderr: '', + exitCode: 1, + timedOut: false, + }); + + const result = await StaticAnalysisService.analyze( + ['src/app.ts'], + repoRoot, + createConfig({ + semgrep: { + enabled: false, + rules: ['auto'], + timeout: 30_000, + }, + qlty: { + enabled: true, + timeout: 30_000, + smells: { + enabled: true, + timeout: 30_000, + includeDuplication: true, + }, + }, + }), + { + commitDiff: createChangedLineDiff(), + } + ); + + expect(result.summary.findings).toHaveLength(2); + expect(result.summary.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ runnerId: 'qlty-check', ruleId: 'qlty/check/changed-line' }), + expect.objectContaining({ runnerId: 'qlty-smells', ruleId: 'qlty/smells/changed-line' }), + ]) + ); + expect(result.summary.findings.some((finding) => finding.ruleId === 'qlty/check/unchanged-line')).toBe( + false + ); + expect(result.summary.findings.some((finding) => finding.ruleId === 'qlty/smells/file-level')).toBe( + false + ); + }); + + it('applies changed-line filtering to semgrep findings when semgrep findings are provided', async () => { + vi.mocked(ToolInstaller.ensureAvailable).mockResolvedValue({ + semgrep: { + available: true, + executable: '/usr/bin/semgrep', + installedNow: false, + }, + qlty: { + available: false, + executable: null, + installedNow: false, + }, + }); + + const semgrepRunner: IToolRunner = { + name: 'semgrep', + scan: vi.fn().mockResolvedValue({ + findings: [ + { + source: 'semgrep', + runnerId: 'semgrep', + file: 'src/app.ts', + line: 11, + column: 1, + message: 'changed semgrep finding', + severity: 'warning', + category: 'security', + ruleId: 'semgrep.changed', + }, + { + source: 'semgrep', + runnerId: 'semgrep', + file: 'src/app.ts', + line: 14, + column: 1, + message: 'unchanged semgrep finding', + severity: 'warning', + category: 'security', + ruleId: 'semgrep.unchanged', + }, + ], + rawOutput: { + stdout: JSON.stringify({ results: [] }), + stderr: '', + exitCode: 0, + timedOut: false, + }, + }), + }; + + vi.spyOn(StaticAnalysisService as any, 'getToolRunnerPlans').mockReturnValue([ + { + id: 'semgrep', + source: 'semgrep', + runnable: true, + timeoutMs: 30_000, + warnings: [], + createRunner: () => semgrepRunner, + }, + ]); + + const result = await StaticAnalysisService.analyze( + ['src/app.ts'], + repoRoot, + createConfig({ + semgrep: { + enabled: true, + rules: ['auto'], + timeout: 30_000, + }, + qlty: { + enabled: false, + timeout: 30_000, + smells: { + enabled: false, + timeout: 30_000, + includeDuplication: true, + }, + }, + }), + { + commitDiff: createChangedLineDiff(), + } + ); + + expect(mockedSpawnAsync).not.toHaveBeenCalled(); + expect(result.summary.findings).toHaveLength(1); + expect(result.summary.findings[0]).toMatchObject({ + runnerId: 'semgrep', + ruleId: 'semgrep.changed', + line: 11, + }); + expect(result.summary.findings.some((finding) => finding.ruleId === 'semgrep.unchanged')).toBe( + false + ); + }); + + it('returns immediately when static analysis is disabled', async () => { + const availabilitySpy = vi.spyOn(ToolInstaller, 'ensureAvailable'); + const result = await StaticAnalysisService.analyze( + ['src/app.ts'], + repoRoot, + createConfig({ enabled: false }) + ); + + expect(availabilitySpy).not.toHaveBeenCalled(); + expect(mockedSpawnAsync).not.toHaveBeenCalled(); + expect(result.summary.counts.total).toBe(0); + expect(result.summary.warnings).toContain('Static analysis disabled by configuration.'); + }); +}); diff --git a/src/services/static-analysis/__tests__/tool-installation.test.ts b/src/services/static-analysis/__tests__/tool-installation.test.ts new file mode 100644 index 0000000..3b61dda --- /dev/null +++ b/src/services/static-analysis/__tests__/tool-installation.test.ts @@ -0,0 +1,483 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { SpawnSyncReturns } from 'child_process'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ToolPaths } from '../tool-paths'; +import { installQlty, ToolInstallationDependencies } from '../tool-installation'; + +function createSpawnResult( + overrides: Partial> +): SpawnSyncReturns { + return { + status: 0, + stdout: '', + stderr: '', + output: ['', '', ''], + pid: 1234, + signal: null, + ...overrides, + } as SpawnSyncReturns; +} + +function isQltyInstallScript(command: string, args: string[]): boolean { + if (process.platform === 'win32') { + return ( + (command.toLowerCase().includes('powershell') || command.toLowerCase().endsWith('pwsh.exe')) && + args.some((arg) => arg.includes('https://qlty.sh')) + ); + } + return command === 'sh' && args.length >= 2 && args[1] === 'curl -fsSL https://qlty.sh | sh'; +} + +function withMockedPlatform(platform: NodeJS.Platform, fn: () => void): void { + const originalDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: platform, configurable: true }); + try { + fn(); + } finally { + if (originalDescriptor) { + Object.defineProperty(process, 'platform', originalDescriptor); + } + } +} + +describe('tool-installation installQlty', () => { + let tempRoot: string; + let qltyExecutable: string; + let brewExecutable: string; + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + const originalLocalAppData = process.env.LOCALAPPDATA; + const originalProgramData = process.env.ProgramData; + const originalPath = process.env.PATH; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tool-installation-')); + process.env.CODEWAVE_TOOLS_DIR = tempRoot; + process.env.HOME = tempRoot; + process.env.USERPROFILE = tempRoot; + process.env.LOCALAPPDATA = path.join(tempRoot, 'AppData', 'Local'); + process.env.ProgramData = path.join(tempRoot, 'ProgramData'); + process.env.PATH = '/usr/bin:/bin'; + + qltyExecutable = path.join(tempRoot, ToolPaths.binaryName('qlty')); + brewExecutable = path.join(tempRoot, ToolPaths.binaryName('brew')); + fs.writeFileSync(qltyExecutable, '#!/bin/sh\necho qlty\n'); + fs.chmodSync(qltyExecutable, 0o755); + fs.writeFileSync(brewExecutable, '#!/bin/sh\necho brew\n'); + fs.chmodSync(brewExecutable, 0o755); + }); + + afterEach(() => { + delete process.env.CODEWAVE_TOOLS_DIR; + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } + if (originalLocalAppData === undefined) { + delete process.env.LOCALAPPDATA; + } else { + process.env.LOCALAPPDATA = originalLocalAppData; + } + if (originalProgramData === undefined) { + delete process.env.ProgramData; + } else { + process.env.ProgramData = originalProgramData; + } + if (originalPath === undefined) { + delete process.env.PATH; + } else { + process.env.PATH = originalPath; + } + fs.rmSync(tempRoot, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('auto-installs xz and retries qlty installer when xz is missing', () => { + let installerCalls = 0; + const runCommand = vi.fn((command: string, args: string[]) => { + if (isQltyInstallScript(command, args)) { + installerCalls += 1; + if (installerCalls === 1) { + return createSpawnResult({ + status: 1, + stderr: 'error: xz is required to install qlty', + }); + } + return createSpawnResult({ status: 0 }); + } + + if (command === brewExecutable && args.join(' ') === 'install xz') { + return createSpawnResult({ status: 0 }); + } + + return createSpawnResult({ status: 1, stderr: 'unexpected command' }); + }); + + const deps: ToolInstallationDependencies = { + runCommand, + isToolInstalled: (executablePath) => ({ installed: fs.existsSync(executablePath) }), + findExecutable: (toolName) => { + if (toolName === 'brew') { + return brewExecutable; + } + if (toolName === 'qlty') { + return qltyExecutable; + } + return null; + }, + allowSystemDependenciesInstall: true, + }; + + const result = installQlty(deps); + + expect(result.success).toBe(true); + expect(result.executable).toBe(ToolPaths.getManagedExecutablePath('qlty')); + expect(installerCalls).toBe(2); + expect(runCommand).toHaveBeenCalledWith(brewExecutable, ['install', 'xz']); + }); + + it('returns a clear error when xz is missing and no package manager is available', () => { + const fallbackPackageManagerPaths = new Set([ + '/opt/homebrew/bin/brew', + '/usr/local/bin/brew', + '/home/linuxbrew/.linuxbrew/bin/brew', + '/usr/bin/apt-get', + '/bin/apt-get', + '/usr/bin/dnf', + '/bin/dnf', + '/usr/bin/yum', + '/bin/yum', + '/usr/bin/microdnf', + '/bin/microdnf', + '/usr/bin/pacman', + '/bin/pacman', + '/usr/bin/zypper', + '/bin/zypper', + '/sbin/apk', + '/usr/sbin/apk', + '/usr/sbin/pkg', + '/sbin/pkg', + '/opt/local/bin/port', + '/usr/bin/nix', + '/bin/nix', + '/nix/var/nix/profiles/default/bin/nix', + '/usr/bin/sudo', + '/bin/sudo', + ]); + const originalExistsSync = fs.existsSync.bind(fs); + vi.spyOn(fs, 'existsSync').mockImplementation((targetPath) => { + const normalized = String(targetPath); + if (fallbackPackageManagerPaths.has(normalized)) { + return false; + } + return originalExistsSync(targetPath); + }); + + const runCommand = vi.fn((command: string, args: string[]) => { + if (isQltyInstallScript(command, args)) { + return createSpawnResult({ + status: 1, + stderr: 'error: xz is required to install qlty', + }); + } + return createSpawnResult({ status: 1, stderr: 'not found' }); + }); + + const deps: ToolInstallationDependencies = { + runCommand, + isToolInstalled: () => ({ installed: false }), + findExecutable: () => null, + allowSystemDependenciesInstall: true, + }; + + const result = installQlty(deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('qlty install requires xz'); + expect(result.error).toContain('no supported package manager'); + expect(result.error).toContain('PATH or common locations'); + }); + + it('provides manual install instructions when system-deps auto-install is disabled', () => { + const runCommand = vi.fn((command: string, args: string[]) => { + if (isQltyInstallScript(command, args)) { + return createSpawnResult({ + status: 1, + stderr: 'error: xz is required to install qlty', + }); + } + return createSpawnResult({ status: 1, stderr: 'unexpected command' }); + }); + + const deps: ToolInstallationDependencies = { + runCommand, + isToolInstalled: () => ({ installed: false }), + findExecutable: () => null, + allowSystemDependenciesInstall: false, + }; + + const result = installQlty(deps); + + expect(result.success).toBe(false); + expect(result.error).toContain('staticAnalysis.autoInstallSystemDeps=true'); + expect(result.error).toContain('Install xz manually and retry'); + expect( + runCommand.mock.calls.some(([, args]) => Array.isArray(args) && args.join(' ').includes('install xz')) + ).toBe(false); + }); + + it('uses brew from common fallback locations when brew is not discoverable in PATH', () => { + const commonBrewPath = '/opt/homebrew/bin/brew'; + let installerCalls = 0; + const originalExistsSync = fs.existsSync.bind(fs); + + vi.spyOn(fs, 'existsSync').mockImplementation((targetPath) => { + if (String(targetPath) === commonBrewPath) { + return true; + } + return originalExistsSync(targetPath); + }); + + const runCommand = vi.fn((command: string, args: string[]) => { + if (isQltyInstallScript(command, args)) { + installerCalls += 1; + if (installerCalls === 1) { + return createSpawnResult({ + status: 1, + stderr: 'error: xz is required to install qlty', + }); + } + return createSpawnResult({ status: 0 }); + } + + if (command === commonBrewPath && args.join(' ') === 'install xz') { + return createSpawnResult({ status: 0 }); + } + + return createSpawnResult({ status: 1, stderr: 'unexpected command' }); + }); + + const deps: ToolInstallationDependencies = { + runCommand, + isToolInstalled: (executablePath) => ({ installed: fs.existsSync(executablePath) }), + findExecutable: (toolName) => { + if (toolName === 'qlty') { + return qltyExecutable; + } + return null; + }, + allowSystemDependenciesInstall: true, + }; + + const result = installQlty(deps); + + expect(result.success).toBe(true); + expect(result.executable).toBe(ToolPaths.getManagedExecutablePath('qlty')); + expect(runCommand).toHaveBeenCalledWith(commonBrewPath, ['install', 'xz']); + expect(installerCalls).toBe(2); + }); + + it('uses macports from common fallback locations when brew is unavailable', () => { + const commonPortPath = '/opt/local/bin/port'; + let installerCalls = 0; + const originalExistsSync = fs.existsSync.bind(fs); + + vi.spyOn(fs, 'existsSync').mockImplementation((targetPath) => { + if (String(targetPath) === commonPortPath) { + return true; + } + return originalExistsSync(targetPath); + }); + + const runCommand = vi.fn((command: string, args: string[]) => { + if (isQltyInstallScript(command, args)) { + installerCalls += 1; + if (installerCalls === 1) { + return createSpawnResult({ + status: 1, + stderr: 'error: xz is required to install qlty', + }); + } + return createSpawnResult({ status: 0 }); + } + + if (command === commonPortPath && args.join(' ') === 'install xz') { + return createSpawnResult({ status: 0 }); + } + + return createSpawnResult({ status: 1, stderr: 'unexpected command' }); + }); + + const deps: ToolInstallationDependencies = { + runCommand, + isToolInstalled: (executablePath) => ({ installed: fs.existsSync(executablePath) }), + findExecutable: (toolName) => { + if (toolName === 'qlty') { + return qltyExecutable; + } + return null; + }, + allowSystemDependenciesInstall: true, + }; + + const result = installQlty(deps); + + expect(result.success).toBe(true); + expect(result.executable).toBe(ToolPaths.getManagedExecutablePath('qlty')); + expect(runCommand).toHaveBeenCalledWith(commonPortPath, ['install', 'xz']); + expect(installerCalls).toBe(2); + }); + + it('falls back to brew install qlty when script install fails for other reasons', () => { + const runCommand = vi.fn((command: string, args: string[]) => { + if (isQltyInstallScript(command, args)) { + return createSpawnResult({ status: 1, stderr: 'temporary network failure' }); + } + if (command === brewExecutable && args.join(' ') === 'install qlty') { + return createSpawnResult({ status: 0 }); + } + return createSpawnResult({ status: 1, stderr: 'unexpected command' }); + }); + + const deps: ToolInstallationDependencies = { + runCommand, + isToolInstalled: (executablePath) => ({ installed: fs.existsSync(executablePath) }), + findExecutable: (toolName) => { + if (toolName === 'brew') { + return brewExecutable; + } + if (toolName === 'qlty') { + return qltyExecutable; + } + return null; + }, + allowSystemDependenciesInstall: true, + }; + + const result = installQlty(deps); + + expect(result.success).toBe(true); + expect(runCommand).toHaveBeenCalledWith(brewExecutable, ['install', 'qlty']); + expect(runCommand).not.toHaveBeenCalledWith(brewExecutable, ['install', 'xz']); + }); + + it('uses winget on Windows before PowerShell fallback and resolves qlty from WinGet links', () => { + withMockedPlatform('win32', () => { + const wingetPath = 'C:\\Windows\\System32\\winget.exe'; + const qltyWinPath = path.join( + process.env.LOCALAPPDATA || '', + 'Microsoft', + 'WinGet', + 'Links', + 'qlty.exe' + ); + fs.mkdirSync(path.dirname(qltyWinPath), { recursive: true }); + fs.writeFileSync(qltyWinPath, 'qlty'); + + const originalExistsSync = fs.existsSync.bind(fs); + vi.spyOn(fs, 'existsSync').mockImplementation((targetPath) => { + if (String(targetPath) === wingetPath) { + return true; + } + return originalExistsSync(targetPath); + }); + + const runCommand = vi.fn((command: string, args: string[]) => { + if ( + command === wingetPath && + args[0] === 'install' && + args.includes('--id') && + args.includes('Qlty.qlty') + ) { + return createSpawnResult({ status: 0 }); + } + return createSpawnResult({ status: 1, stderr: 'unexpected command' }); + }); + + const deps: ToolInstallationDependencies = { + runCommand, + isToolInstalled: (executablePath) => ({ installed: fs.existsSync(executablePath) }), + findExecutable: (toolName) => { + if (toolName === 'qlty') { + return null; + } + return null; + }, + allowSystemDependenciesInstall: true, + }; + + const result = installQlty(deps); + + expect(result.success).toBe(true); + expect(runCommand).toHaveBeenCalledWith( + wingetPath, + expect.arrayContaining(['install', '--id', 'Qlty.qlty']) + ); + expect(process.env.PATH).toContain(path.dirname(qltyWinPath)); + }); + }); + + it('on Windows installs xz via Chocolatey and retries PowerShell qlty install', () => { + withMockedPlatform('win32', () => { + const chocoPath = path.join(process.env.ProgramData || '', 'chocolatey', 'bin', 'choco.exe'); + const powershellPath = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe'; + const qltyWinPath = path.join(process.env.USERPROFILE || '', '.qlty', 'bin', 'qlty.exe'); + fs.mkdirSync(path.dirname(qltyWinPath), { recursive: true }); + fs.writeFileSync(qltyWinPath, 'qlty'); + + let scriptCalls = 0; + const originalExistsSync = fs.existsSync.bind(fs); + vi.spyOn(fs, 'existsSync').mockImplementation((targetPath) => { + const normalized = String(targetPath); + if (normalized === chocoPath || normalized === powershellPath) { + return true; + } + if (normalized.endsWith(path.join('Microsoft', 'WindowsApps', 'winget.exe'))) { + return false; + } + return originalExistsSync(targetPath); + }); + + const runCommand = vi.fn((command: string, args: string[]) => { + if (command === chocoPath && args.join(' ') === 'install qlty -y') { + return createSpawnResult({ status: 1, stderr: 'package not found' }); + } + if (command === chocoPath && args.join(' ') === 'install xz -y') { + return createSpawnResult({ status: 0 }); + } + if (isQltyInstallScript(command, args)) { + scriptCalls += 1; + if (scriptCalls === 1) { + return createSpawnResult({ + status: 1, + stderr: 'error: xz is required to install qlty', + }); + } + return createSpawnResult({ status: 0 }); + } + return createSpawnResult({ status: 1, stderr: 'unexpected command' }); + }); + + const deps: ToolInstallationDependencies = { + runCommand, + isToolInstalled: (executablePath) => ({ installed: fs.existsSync(executablePath) }), + findExecutable: () => null, + allowSystemDependenciesInstall: true, + }; + + const result = installQlty(deps); + + expect(result.success).toBe(true); + expect(runCommand).toHaveBeenCalledWith(chocoPath, ['install', 'xz', '-y']); + expect(scriptCalls).toBe(2); + }); + }); +}); diff --git a/src/services/static-analysis/__tests__/tool-installer.test.ts b/src/services/static-analysis/__tests__/tool-installer.test.ts new file mode 100644 index 0000000..c731617 --- /dev/null +++ b/src/services/static-analysis/__tests__/tool-installer.test.ts @@ -0,0 +1,203 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { SpawnSyncReturns } from 'child_process'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LoggerService } from '../../../common/logger'; +import { SilentTransport } from '../../../common/logger/transports/silent.transport'; +import { ToolInstaller } from '../tool-installer'; + +function createSpawnResult(overrides: Partial>): SpawnSyncReturns { + return { + status: 0, + stdout: '', + stderr: '', + output: ['', '', ''], + pid: 1234, + signal: null, + ...overrides, + } as SpawnSyncReturns; +} + +describe('ToolInstaller', () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tool-installer-')); + LoggerService.setTransport(new SilentTransport()); + ToolInstaller.resetForTests(); + process.env.CODEWAVE_TOOLS_DIR = tempRoot; + }); + + afterEach(() => { + delete process.env.CODEWAVE_TOOLS_DIR; + ToolInstaller.resetForTests(); + LoggerService.resetForTests(); + vi.restoreAllMocks(); + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('returns available status when both tools are already found', async () => { + const findSpy = vi + .spyOn(ToolInstaller, 'findExecutable') + .mockImplementation((tool) => `/usr/bin/${tool}`); + const semgrepInstallSpy = vi.spyOn(ToolInstaller, 'installSemgrep'); + const qltyInstallSpy = vi.spyOn(ToolInstaller, 'installQlty'); + + const result = await ToolInstaller.ensureAvailable(false); + + expect(result.semgrep.available).toBe(true); + expect(result.semgrep.executable).toBe('/usr/bin/semgrep'); + expect(result.semgrep.installedNow).toBe(false); + + expect(result.qlty.available).toBe(true); + expect(result.qlty.executable).toBe('/usr/bin/qlty'); + expect(result.qlty.installedNow).toBe(false); + + expect(findSpy).toHaveBeenCalledTimes(2); + expect(semgrepInstallSpy).not.toHaveBeenCalled(); + expect(qltyInstallSpy).not.toHaveBeenCalled(); + }); + + it('does not install missing tools when autoInstall is false', async () => { + vi.spyOn(ToolInstaller, 'findExecutable').mockReturnValue(null); + const semgrepInstallSpy = vi.spyOn(ToolInstaller, 'installSemgrep'); + const qltyInstallSpy = vi.spyOn(ToolInstaller, 'installQlty'); + + const result = await ToolInstaller.ensureAvailable(false); + + expect(result.semgrep.available).toBe(false); + expect(result.semgrep.installedNow).toBe(false); + expect(result.semgrep.error).toContain('auto-install is disabled'); + + expect(result.qlty.available).toBe(false); + expect(result.qlty.installedNow).toBe(false); + expect(result.qlty.error).toContain('auto-install is disabled'); + + expect(semgrepInstallSpy).not.toHaveBeenCalled(); + expect(qltyInstallSpy).not.toHaveBeenCalled(); + }); + + it('attempts install when tools are missing and autoInstall is true', async () => { + vi.spyOn(ToolInstaller, 'findExecutable').mockReturnValue(null); + vi.spyOn(ToolInstaller, 'installSemgrep').mockReturnValue({ + success: true, + executable: '/managed/semgrep', + }); + vi.spyOn(ToolInstaller, 'installQlty').mockReturnValue({ + success: false, + executable: null, + error: 'qlty install failed', + }); + + const result = await ToolInstaller.ensureAvailable(true); + + expect(result.semgrep.available).toBe(true); + expect(result.semgrep.executable).toBe('/managed/semgrep'); + expect(result.semgrep.installedNow).toBe(true); + + expect(result.qlty.available).toBe(false); + expect(result.qlty.executable).toBeNull(); + expect(result.qlty.installedNow).toBe(false); + expect(result.qlty.error).toContain('qlty install failed'); + }); + + it('uses cache so repeated ensureAvailable calls with same mode do not re-check tools', async () => { + const findSpy = vi + .spyOn(ToolInstaller, 'findExecutable') + .mockImplementation((tool) => `/usr/bin/${tool}`); + + const first = await ToolInstaller.ensureAvailable(false); + const second = await ToolInstaller.ensureAvailable(false); + + expect(findSpy).toHaveBeenCalledTimes(2); + expect(second).toStrictEqual(first); + }); + + it('does not reuse no-auto-install cache when autoInstall mode changes', async () => { + vi.spyOn(ToolInstaller, 'findExecutable').mockReturnValue(null); + const semgrepInstallSpy = vi.spyOn(ToolInstaller, 'installSemgrep').mockReturnValue({ + success: true, + executable: '/managed/semgrep', + }); + const qltyInstallSpy = vi.spyOn(ToolInstaller, 'installQlty').mockReturnValue({ + success: true, + executable: '/managed/qlty', + }); + + const noInstallResult = await ToolInstaller.ensureAvailable(false); + const installResult = await ToolInstaller.ensureAvailable(true); + + expect(noInstallResult.semgrep.available).toBe(false); + expect(noInstallResult.qlty.available).toBe(false); + expect(installResult.semgrep.available).toBe(true); + expect(installResult.semgrep.installedNow).toBe(true); + expect(installResult.qlty.available).toBe(true); + expect(installResult.qlty.installedNow).toBe(true); + expect(semgrepInstallSpy).toHaveBeenCalledTimes(1); + expect(qltyInstallSpy).toHaveBeenCalledTimes(1); + }); + + it('does not reuse cache when autoInstallSystemDeps mode changes', async () => { + vi.spyOn(ToolInstaller, 'findExecutable').mockReturnValue(null); + const semgrepInstallSpy = vi.spyOn(ToolInstaller, 'installSemgrep').mockReturnValue({ + success: true, + executable: '/managed/semgrep', + }); + const qltyInstallSpy = vi.spyOn(ToolInstaller, 'installQlty').mockReturnValue({ + success: true, + executable: '/managed/qlty', + }); + + const first = await ToolInstaller.ensureAvailable(true, false); + const second = await ToolInstaller.ensureAvailable(true, true); + + expect(first.semgrep.available).toBe(true); + expect(first.qlty.available).toBe(true); + expect(second.semgrep.available).toBe(true); + expect(second.qlty.available).toBe(true); + expect(semgrepInstallSpy).toHaveBeenCalledTimes(2); + expect(qltyInstallSpy).toHaveBeenCalledTimes(2); + expect(qltyInstallSpy).toHaveBeenNthCalledWith(1, false); + expect(qltyInstallSpy).toHaveBeenNthCalledWith(2, true); + }); + + it('findExecutable resolves a tool from system path first', () => { + const lookupCommand = process.platform === 'win32' ? 'where' : 'which'; + const binaryName = process.platform === 'win32' ? 'semgrep.exe' : 'semgrep'; + const toolPath = path.join(tempRoot, binaryName); + fs.writeFileSync(toolPath, 'placeholder'); + + vi.spyOn(ToolInstaller, 'isToolInstalled').mockReturnValue({ installed: true, version: 'semgrep 1.0.0' }); + + ToolInstaller.setCommandRunnerForTests((command, _args) => { + if (command === lookupCommand) { + return createSpawnResult({ status: 0, stdout: `${toolPath}\n` }); + } + return createSpawnResult({ status: 1, stderr: 'not found' }); + }); + + const found = ToolInstaller.findExecutable('semgrep'); + expect(found).toBe(toolPath); + }); + + it('findExecutable falls back to managed path when system lookup misses', () => { + const lookupCommand = process.platform === 'win32' ? 'where' : 'which'; + const binaryName = process.platform === 'win32' ? 'semgrep.exe' : 'semgrep'; + const managedPath = path.join(tempRoot, 'bin', binaryName); + fs.mkdirSync(path.dirname(managedPath), { recursive: true }); + fs.writeFileSync(managedPath, 'placeholder'); + + vi.spyOn(ToolInstaller, 'isToolInstalled').mockReturnValue({ installed: true, version: 'semgrep 1.0.0' }); + + ToolInstaller.setCommandRunnerForTests((command, _args) => { + if (command === lookupCommand) { + return createSpawnResult({ status: 1, stderr: 'not found' }); + } + return createSpawnResult({ status: 1, stderr: 'not found' }); + }); + + const found = ToolInstaller.findExecutable('semgrep'); + expect(found).toBe(managedPath); + }); +}); diff --git a/src/services/static-analysis/__tests__/tool-runner-registry.test.ts b/src/services/static-analysis/__tests__/tool-runner-registry.test.ts new file mode 100644 index 0000000..a7c2dbc --- /dev/null +++ b/src/services/static-analysis/__tests__/tool-runner-registry.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from 'vitest'; +import { buildToolRunnerPlans } from '../tool-runner-registry'; +import { StaticAnalysisConfig, ToolAvailability } from '../types'; + +function createConfig(overrides: Partial = {}): StaticAnalysisConfig { + return { + enabled: true, + autoInstall: false, + autoInstallSystemDeps: false, + excludedPaths: [], + semgrep: { + enabled: true, + rules: ['auto'], + timeout: 30_000, + }, + qlty: { + enabled: false, + timeout: 30_000, + smells: { + enabled: true, + timeout: 30_000, + includeDuplication: true, + }, + }, + maxFindings: 500, + minSeverity: 'warning', + ...overrides, + }; +} + +function createAvailability(overrides: Partial = {}): ToolAvailability { + return { + semgrep: { + available: true, + executable: '/usr/bin/semgrep', + installedNow: false, + }, + qlty: { + available: false, + executable: null, + installedNow: false, + }, + ...overrides, + }; +} + +describe('buildToolRunnerPlans', () => { + it('marks semgrep runnable when enabled and available', () => { + const plans = buildToolRunnerPlans(createConfig(), createAvailability()); + const semgrepPlan = plans[0]; + + expect(semgrepPlan.id).toBe('semgrep'); + expect(semgrepPlan.source).toBe('semgrep'); + expect(semgrepPlan.runnable).toBe(true); + expect(semgrepPlan.warnings).toEqual([]); + expect(semgrepPlan.createRunner).not.toBeNull(); + expect(semgrepPlan.createRunner?.().name).toBe('semgrep'); + }); + + it('does not run semgrep when semgrep is disabled in config', () => { + const plans = buildToolRunnerPlans( + createConfig({ + semgrep: { + enabled: false, + rules: ['auto'], + timeout: 30_000, + }, + }), + createAvailability() + ); + + const semgrepPlan = plans[0]; + expect(semgrepPlan.runnable).toBe(false); + expect(semgrepPlan.warnings).toEqual([]); + expect(semgrepPlan.createRunner).toBeNull(); + }); + + it('surfaces semgrep availability error when semgrep is enabled but unavailable', () => { + const plans = buildToolRunnerPlans( + createConfig(), + createAvailability({ + semgrep: { + available: false, + executable: null, + installedNow: false, + error: 'semgrep failed to install', + }, + }) + ); + + const semgrepPlan = plans[0]; + expect(semgrepPlan.runnable).toBe(false); + expect(semgrepPlan.warnings).toEqual(['semgrep failed to install']); + expect(semgrepPlan.createRunner).toBeNull(); + }); + + it('marks qlty check and qlty smells runnable when enabled and available', () => { + const plans = buildToolRunnerPlans( + createConfig({ + qlty: { + enabled: true, + timeout: 15_000, + smells: { + enabled: true, + timeout: 11_000, + includeDuplication: false, + }, + }, + }), + createAvailability({ + qlty: { + available: true, + executable: '/usr/bin/qlty', + installedNow: false, + }, + }) + ); + + const qltyCheckPlan = plans[1]; + const qltySmellsPlan = plans[2]; + + expect(qltyCheckPlan.id).toBe('qlty-check'); + expect(qltyCheckPlan.source).toBe('qlty'); + expect(qltyCheckPlan.runnable).toBe(true); + expect(qltyCheckPlan.warnings).toEqual([]); + expect(qltyCheckPlan.createRunner).not.toBeNull(); + expect(qltyCheckPlan.createRunner?.().name).toBe('qlty'); + + expect(qltySmellsPlan.id).toBe('qlty-smells'); + expect(qltySmellsPlan.source).toBe('qlty'); + expect(qltySmellsPlan.runnable).toBe(true); + expect(qltySmellsPlan.warnings).toEqual([]); + expect(qltySmellsPlan.createRunner).not.toBeNull(); + expect(qltySmellsPlan.createRunner?.().name).toBe('qlty'); + }); + + it('does not run qlty smells when smells are disabled', () => { + const plans = buildToolRunnerPlans( + createConfig({ + qlty: { + enabled: true, + timeout: 30_000, + smells: { + enabled: false, + timeout: 30_000, + includeDuplication: true, + }, + }, + }), + createAvailability({ + qlty: { + available: true, + executable: '/usr/bin/qlty', + installedNow: false, + }, + }) + ); + + const qltyCheckPlan = plans[1]; + const qltySmellsPlan = plans[2]; + + expect(qltyCheckPlan.runnable).toBe(true); + expect(qltySmellsPlan.runnable).toBe(false); + expect(qltySmellsPlan.createRunner).toBeNull(); + }); + + it('surfaces qlty availability error once when qlty is enabled but unavailable', () => { + const plans = buildToolRunnerPlans( + createConfig({ + qlty: { + enabled: true, + timeout: 15_000, + smells: { + enabled: true, + timeout: 15_000, + includeDuplication: true, + }, + }, + }), + createAvailability({ + qlty: { + available: false, + executable: null, + installedNow: false, + error: 'qlty failed to install', + }, + }) + ); + + const qltyCheckPlan = plans[1]; + const qltySmellsPlan = plans[2]; + + expect(qltyCheckPlan.runnable).toBe(false); + expect(qltyCheckPlan.createRunner).toBeNull(); + expect(qltyCheckPlan.warnings).toEqual(['qlty failed to install']); + + expect(qltySmellsPlan.runnable).toBe(false); + expect(qltySmellsPlan.createRunner).toBeNull(); + expect(qltySmellsPlan.warnings).toEqual([]); + }); + + it('keeps plan order semgrep then qlty-check then qlty-smells', () => { + const plans = buildToolRunnerPlans(createConfig(), createAvailability()); + expect(plans.map((plan) => plan.id)).toEqual(['semgrep', 'qlty-check', 'qlty-smells']); + }); +}); diff --git a/src/services/static-analysis/changed-lines-scope.ts b/src/services/static-analysis/changed-lines-scope.ts new file mode 100644 index 0000000..7b6354d --- /dev/null +++ b/src/services/static-analysis/changed-lines-scope.ts @@ -0,0 +1,281 @@ +import path from 'path'; + +export interface ChangedLineRange { + start: number; + end: number; +} + +export interface ChangedLineParseResult { + rangesByFile: Map; + parseable: boolean; +} + +export function parseChangedLineRangesFromUnifiedDiff(diff: string): ChangedLineParseResult { + if (typeof diff !== 'string' || !diff.trim()) { + return { + rangesByFile: new Map(), + parseable: false, + }; + } + + const lines = diff.split(/\r?\n/); + const lineNumbersByFile = new Map(); + + let hasDiffMarkers = false; + let currentFile: string | null = null; + let currentNewLine: number | null = null; + + for (const line of lines) { + if (line.startsWith('diff --git ')) { + hasDiffMarkers = true; + currentNewLine = null; + + // Git C-quotes paths with special characters or spaces: + // diff --git "a/old name.ts" "b/new name.ts" + const quotedMatch = line.match( + /^diff --git "a\/(?:[^"\\]|\\.)*" "b\/((?:[^"\\]|\\.)+)"$/ + ); + if (quotedMatch) { + currentFile = unquoteGitPath(quotedMatch[1]); + continue; + } + + // Standard: diff --git a/old.ts b/new.ts + const match = line.match(/^diff --git a\/.+? b\/(.+)$/); + if (!match) { + currentFile = null; + continue; + } + + currentFile = normalizeDiffPath(match[1]); + continue; + } + + if (line.startsWith('+++ ')) { + const plusPath = line.slice(4).trim(); + if (plusPath === '/dev/null') { + currentFile = null; + continue; + } + + const normalized = normalizeDiffPath(plusPath); + if (normalized) { + currentFile = normalized; + } + continue; + } + + const hunkMatch = line.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/); + if (hunkMatch) { + hasDiffMarkers = true; + currentNewLine = Number.parseInt(hunkMatch[1], 10); + continue; + } + + if (!currentFile || currentNewLine === null) { + continue; + } + + if (line.startsWith('+') && !line.startsWith('+++')) { + if (currentNewLine > 0) { + const fileNumbers = lineNumbersByFile.get(currentFile) || []; + fileNumbers.push(currentNewLine); + lineNumbersByFile.set(currentFile, fileNumbers); + } + currentNewLine += 1; + continue; + } + + if (line.startsWith('-') && !line.startsWith('---')) { + continue; + } + + if (line.startsWith(' ')) { + currentNewLine += 1; + continue; + } + + if (line.startsWith('\\ No newline at end of file')) { + continue; + } + } + + const rangesByFile = new Map(); + for (const [file, lineNumbers] of lineNumbersByFile.entries()) { + const ranges = compressLineNumbers(lineNumbers); + if (ranges.length > 0) { + rangesByFile.set(file, ranges); + } + } + + return { + rangesByFile, + parseable: hasDiffMarkers, + }; +} + +export function findingMatchesChangedLines( + filePath: string, + line: number, + rangesByFile: Map, + repoPath: string +): boolean { + if (!Number.isInteger(line) || line <= 0) { + return false; + } + + const normalizedFile = normalizePathToRepoRelative(filePath, repoPath); + if (!normalizedFile) { + return false; + } + + const ranges = rangesByFile.get(normalizedFile); + if (!ranges || ranges.length === 0) { + return false; + } + + return ranges.some((range) => line >= range.start && line <= range.end); +} + +export function normalizePathToRepoRelative(filePath: string, repoPath: string): string | null { + if (typeof filePath !== 'string') { + return null; + } + + let normalizedInput = filePath.trim(); + if (!normalizedInput) { + return null; + } + + if (normalizedInput.startsWith('file://')) { + try { + const url = new URL(normalizedInput); + normalizedInput = decodeURIComponent(url.pathname); + if (process.platform === 'win32') { + normalizedInput = normalizedInput.replace(/^\/([A-Za-z]:\/)/, '$1'); + } + } catch { + normalizedInput = normalizedInput.replace(/^file:\/\//, ''); + } + } + + normalizedInput = normalizedInput.replace(/^\.\/+/, '').split('\\').join('/'); + + const repoRoot = path.resolve(repoPath); + const absolutePath = path.isAbsolute(normalizedInput) + ? path.resolve(normalizedInput) + : path.resolve(repoRoot, normalizedInput); + const relativePath = path.relative(repoRoot, absolutePath); + + if (!relativePath || relativePath === '.' || relativePath.startsWith('..')) { + return null; + } + + return normalizeDiffPath(relativePath); +} + +function normalizeDiffPath(filePath: string): string | null { + if (typeof filePath !== 'string') { + return null; + } + + let cleaned = filePath.trim().replace(/^(\+\+\+|---)\s+/, ''); + if (!cleaned || cleaned === '/dev/null') { + return null; + } + + // Unquote BEFORE any backslash normalization β€” otherwise octal escape + // sequences like \303\251 get corrupted by the \ β†’ / replacement below. + if (cleaned.startsWith('"') && cleaned.endsWith('"')) { + cleaned = unquoteGitPath(cleaned.slice(1, -1)); + } else { + // Only normalize backslashes for plain (Windows-style) paths + cleaned = cleaned.replace(/\\/g, '/'); + } + + if (!cleaned || cleaned === '/dev/null') { + return null; + } + + const withoutPrefix = cleaned.replace(/^(a|b)\//, '').replace(/^\.\//, ''); + return withoutPrefix || null; +} + +/** + * Decode git C-style path escaping: consecutive octal byte sequences + * (\303\251) for non-ASCII characters and simple escapes (\\, \n, \t, \"). + * Input is the raw content inside the surrounding quotes (quotes already stripped). + */ +function unquoteGitPath(escaped: string): string { + let result = ''; + const pendingBytes: number[] = []; + let i = 0; + + const flushBytes = () => { + if (pendingBytes.length > 0) { + result += Buffer.from(pendingBytes).toString('utf8'); + pendingBytes.length = 0; + } + }; + + while (i < escaped.length) { + if (escaped[i] !== '\\' || i + 1 >= escaped.length) { + flushBytes(); + result += escaped[i]; + i++; + continue; + } + + const next = escaped[i + 1]; + if ( + /^[0-7]$/.test(next) && + i + 3 < escaped.length && + /^[0-7]{2}$/.test(escaped.slice(i + 2, i + 4)) + ) { + pendingBytes.push(parseInt(escaped.slice(i + 1, i + 4), 8)); + i += 4; + continue; + } + + flushBytes(); + const escMap: Record = { '\\': '\\', n: '\n', t: '\t', r: '\r', '"': '"' }; + result += escMap[next] ?? next; + i += 2; + } + + flushBytes(); + return result; +} + +function compressLineNumbers(lineNumbers: number[]): ChangedLineRange[] { + if (lineNumbers.length === 0) { + return []; + } + + const sortedUnique = Array.from( + new Set(lineNumbers.filter((line) => Number.isInteger(line) && line > 0)) + ).sort((a, b) => a - b); + + if (sortedUnique.length === 0) { + return []; + } + + const ranges: ChangedLineRange[] = []; + let rangeStart = sortedUnique[0]; + let previous = sortedUnique[0]; + + for (let i = 1; i < sortedUnique.length; i += 1) { + const current = sortedUnique[i]; + if (current === previous + 1) { + previous = current; + continue; + } + + ranges.push({ start: rangeStart, end: previous }); + rangeStart = current; + previous = current; + } + + ranges.push({ start: rangeStart, end: previous }); + return ranges; +} diff --git a/src/services/static-analysis/file-scope-resolver.ts b/src/services/static-analysis/file-scope-resolver.ts new file mode 100644 index 0000000..81bd03d --- /dev/null +++ b/src/services/static-analysis/file-scope-resolver.ts @@ -0,0 +1,108 @@ +import fs from 'fs'; +import path from 'path'; +import { LoggerService } from '../../common/logger'; +import { FileScopeResult } from './types'; + +const STATIC_ANALYSIS_LOG_CATEGORY = 'static-analysis'; +const picomatch = require('picomatch') as ( + pattern: string, + options?: { dot?: boolean } +) => (input: string) => boolean; + +export class FileScopeResolver { + static resolve(filesChanged: string[], repoPath: string, excludedPaths: string[]): FileScopeResult { + const scanFiles: string[] = []; + const skippedMissing: string[] = []; + const skippedExcluded: string[] = []; + const skippedUnsupported: string[] = []; + const seen = new Set(); + const repoRoot = path.resolve(repoPath); + const excludeMatchers = FileScopeResolver.createExcludeMatchers(excludedPaths); + + for (const rawFilePath of filesChanged) { + const normalized = FileScopeResolver.normalizeToRepoRelativePath(rawFilePath, repoRoot); + if (!normalized) { + if (typeof rawFilePath === 'string' && rawFilePath.trim().length > 0) { + skippedUnsupported.push(rawFilePath); + } + continue; + } + + if (seen.has(normalized)) { + continue; + } + seen.add(normalized); + + if (FileScopeResolver.isExcluded(normalized, excludeMatchers)) { + skippedExcluded.push(normalized); + continue; + } + + const absolutePath = path.resolve(repoRoot, normalized); + if (!FileScopeResolver.fileExists(absolutePath)) { + skippedMissing.push(normalized); + LoggerService.warn(`Skipping missing file from static analysis scope: ${normalized}`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + continue; + } + + scanFiles.push(normalized); + } + + return { + scanFiles, + skippedMissing, + skippedExcluded, + skippedUnsupported, + }; + } + + private static normalizeToRepoRelativePath(filePath: string, repoRoot: string): string | null { + if (typeof filePath !== 'string') { + return null; + } + + const trimmed = filePath.trim(); + if (!trimmed) { + return null; + } + + const absolutePath = path.isAbsolute(trimmed) + ? path.resolve(trimmed) + : path.resolve(repoRoot, trimmed); + const relativeToRepo = path.relative(repoRoot, absolutePath); + if (!relativeToRepo || relativeToRepo === '.' || relativeToRepo.startsWith('..')) { + return null; + } + + return relativeToRepo.split(path.sep).join('/'); + } + + private static fileExists(filePath: string): boolean { + try { + return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); + } catch { + return false; + } + } + + private static createExcludeMatchers(excludedPaths: string[]): Array<(input: string) => boolean> { + if (!excludedPaths || excludedPaths.length === 0) { + return []; + } + + return excludedPaths + .map((pattern) => pattern.trim().replace(/\\/g, '/')) + .filter(Boolean) + .map((normalizedPattern) => picomatch(normalizedPattern, { dot: true })); + } + + private static isExcluded(filePath: string, matchers: Array<(input: string) => boolean>): boolean { + if (matchers.length === 0) { + return false; + } + + return matchers.some((matches) => matches(filePath)); + } +} diff --git a/src/services/static-analysis/finding-formatter.ts b/src/services/static-analysis/finding-formatter.ts new file mode 100644 index 0000000..d4eb6de --- /dev/null +++ b/src/services/static-analysis/finding-formatter.ts @@ -0,0 +1,268 @@ +import { AGENT_EXPERTISE_WEIGHTS } from '../../constants/agent-weights.constants'; +import type { + FindingCategory, + StaticAnalysisFinding, + StaticAnalysisSummary, +} from './types'; +import { SEVERITY_RANK } from './types'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type AgentName = keyof typeof AGENT_EXPERTISE_WEIGHTS; + +export interface FormattedStaticAnalysis { + riskProfile: string; + agentFindings: string; + estimatedTokens: number; + findingsIncluded: number; + totalFindings: number; +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const CATEGORY_ROUTING: Record< + FindingCategory, + { primary: AgentName; secondary: AgentName[] } +> = { + security: { primary: 'senior-architect', secondary: ['business-analyst', 'developer-reviewer'] }, + quality: { primary: 'developer-reviewer', secondary: ['developer-author'] }, + complexity: { + primary: 'senior-architect', + secondary: ['developer-author', 'developer-reviewer'], + }, + duplication: { primary: 'senior-architect', secondary: ['developer-reviewer'] }, + style: { primary: 'developer-reviewer', secondary: [] }, + bug: { primary: 'developer-reviewer', secondary: ['developer-author', 'sdet'] }, +}; + +/** Safety cap to prevent pathological edge cases (e.g., thousands of findings). */ +export const MAX_FINDINGS_SAFETY_CAP = 200; + +// --------------------------------------------------------------------------- +// formatFindingLine +// --------------------------------------------------------------------------- + +const MAX_MESSAGE_LENGTH = 120; + +/** + * Format a single finding as a one-line string. + * + * Format: `[SEVERITY][category] file:line - message (source:ruleId) [CWE-xx] [OWASP-xx] [fixable]` + */ +export function formatFindingLine(finding: StaticAnalysisFinding): string { + const severity = finding.severity.toUpperCase(); + const message = + finding.message.length > MAX_MESSAGE_LENGTH + ? finding.message.slice(0, MAX_MESSAGE_LENGTH) + '...' + : finding.message; + + let line = `[${severity}][${finding.category}] ${finding.file}:${finding.line} - ${message} (${finding.source}:${finding.ruleId})`; + + if (finding.cwe && finding.cwe.length > 0) { + line += ` [${finding.cwe[0]}]`; + } + if (finding.owasp && finding.owasp.length > 0) { + line += ` [${finding.owasp[0]}]`; + } + if (finding.fixable) { + line += ' [fixable]'; + } + + return line; +} + +// --------------------------------------------------------------------------- +// buildRiskProfile +// --------------------------------------------------------------------------- + +/** + * Build a rule-based risk profile string from a static analysis summary. + * No LLM call β€” pure counting logic. + */ +export function buildRiskProfile(summary: StaticAnalysisSummary): string { + const { counts } = summary; + + // Risk level + let riskLevel: string; + let riskReason: string; + if (counts.errors >= 3) { + riskLevel = 'HIGH'; + riskReason = `${counts.errors} errors detected`; + } else if (counts.errors >= 1) { + riskLevel = 'MEDIUM'; + riskReason = `${counts.errors} error(s) detected`; + } else if (counts.warnings >= 5) { + riskLevel = 'MODERATE'; + riskReason = `${counts.warnings} warnings detected`; + } else { + riskLevel = 'LOW'; + riskReason = 'no critical issues detected'; + } + + // Category breakdown + const categoryParts = Object.entries(counts.byCategory) + .filter(([, count]) => count > 0) + .map(([cat, count]) => `${cat}=${count}`) + .join(', '); + + // Hotspot files (top 3 by count) + const hotspotFiles = Object.entries(counts.byFile) + .sort(([, a], [, b]) => b - a) + .slice(0, 3) + .map(([file, count]) => `${file} (${count})`) + .join(', '); + + const lines = [ + '--- Risk Profile ---', + `Findings: ${counts.total} total (${counts.errors} errors, ${counts.warnings} warnings, ${counts.info} info)`, + ]; + + if (categoryParts) { + lines.push(`By category: ${categoryParts}`); + } + if (hotspotFiles) { + lines.push(`Hotspot files: ${hotspotFiles}`); + } + lines.push(`Risk: ${riskLevel} (${riskReason})`); + lines.push('---'); + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// filterFindingsForAgent +// --------------------------------------------------------------------------- + +/** + * Filter findings relevant to a given agent based on the CATEGORY_ROUTING table. + * + * - PRIMARY agent for a category gets ALL severities. + * - SECONDARY agents get only `error` + `warning` (no `info`). + * - Results sorted by severity desc -> file -> line. + */ +export function filterFindingsForAgent( + findings: StaticAnalysisFinding[], + agentName: AgentName +): StaticAnalysisFinding[] { + const primaryCategories = new Set(); + const secondaryCategories = new Set(); + + for (const [category, routing] of Object.entries(CATEGORY_ROUTING) as [ + FindingCategory, + { primary: AgentName; secondary: AgentName[] }, + ][]) { + if (routing.primary === agentName) { + primaryCategories.add(category); + } else if (routing.secondary.includes(agentName)) { + secondaryCategories.add(category); + } + } + + const filtered = findings.filter((f) => { + if (primaryCategories.has(f.category)) { + return true; // Primary: all severities + } + if (secondaryCategories.has(f.category)) { + return f.severity !== 'info'; // Secondary: error + warning only + } + return false; + }); + + // Sort: severity desc -> file -> line + return filtered.sort((a, b) => { + const sevDiff = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]; + if (sevDiff !== 0) return sevDiff; + const fileDiff = a.file.localeCompare(b.file); + if (fileDiff !== 0) return fileDiff; + return a.line - b.line; + }); +} + +// --------------------------------------------------------------------------- +// formatForAgent +// --------------------------------------------------------------------------- + +/** + * Build the full formatted static analysis section for an agent's prompt. + * + * Combines risk profile + filtered & formatted findings. + * All expertise-filtered findings are included, capped only at + * MAX_FINDINGS_SAFETY_CAP for pathological edge cases. + */ +export function formatForAgent( + summary: StaticAnalysisSummary, + agentName: AgentName, +): FormattedStaticAnalysis { + const riskProfile = buildRiskProfile(summary); + const filtered = filterFindingsForAgent(summary.findings, agentName); + const capped = filtered.slice(0, MAX_FINDINGS_SAFETY_CAP); + + const formattedLines = capped.map( + (f, i) => `${i + 1}. ${formatFindingLine(f)}` + ); + + // Build the full section + const header = `## Additional Context: Static Analysis Tool Results + +Automated tools (Semgrep, Qlty) scanned the changed code and produced the +findings below. These are provided as supplementary context β€” tools can +produce false positives, and context matters. Use your professional judgment +to determine their relevance and impact on your assessment. + +${riskProfile}`; + + let findingsBlock: string; + if (formattedLines.length === 0) { + findingsBlock = '\nNo findings relevant to your expertise were detected.'; + } else { + const countLabel = `${formattedLines.length} of ${summary.counts.total}`; + findingsBlock = `\n### Findings Relevant to Your Expertise (${countLabel}):\n${formattedLines.join('\n')}`; + if (filtered.length > MAX_FINDINGS_SAFETY_CAP) { + findingsBlock += `\n\n_(${filtered.length - MAX_FINDINGS_SAFETY_CAP} additional findings omitted β€” safety cap reached)_`; + } + } + + const agentFindings = header + findingsBlock + '\n'; + + // Rough token estimate (~4 chars per token) + const estimatedTokens = Math.ceil(agentFindings.length / 4); + + return { + riskProfile, + agentFindings, + estimatedTokens, + findingsIncluded: formattedLines.length, + totalFindings: summary.counts.total, + }; +} + +// --------------------------------------------------------------------------- +// buildRound2Reference +// --------------------------------------------------------------------------- + +/** + * Build a condensed reference for Round 2+ prompts. + * Contains the risk profile + any error-severity findings only. + */ +export function buildRound2Reference(summary: StaticAnalysisSummary): string { + const riskProfile = buildRiskProfile(summary); + + const errorFindings = summary.findings.filter((f) => f.severity === 'error'); + + let section = `## Static Analysis Reference (Round 2+) + +Full findings were provided in Round 1. Below is a summary for reference. + +${riskProfile}`; + + if (errorFindings.length > 0) { + const lines = errorFindings.map((f, i) => `${i + 1}. ${formatFindingLine(f)}`); + section += `\n\n### Critical Findings (errors only):\n${lines.join('\n')}`; + } + + return section + '\n'; +} diff --git a/src/services/static-analysis/index.ts b/src/services/static-analysis/index.ts new file mode 100644 index 0000000..c294d16 --- /dev/null +++ b/src/services/static-analysis/index.ts @@ -0,0 +1,9 @@ +export * from './types'; +export * from './spawn-helper'; +export * from './file-scope-resolver'; +export * from './tool-installer'; +export * from './semgrep-runner'; +export * from './qlty-runner'; +export * from './qlty-smells-runner'; +export * from './static-analysis.service'; +export * from './finding-formatter'; diff --git a/src/services/static-analysis/qlty-init-helper.ts b/src/services/static-analysis/qlty-init-helper.ts new file mode 100644 index 0000000..e494ce1 --- /dev/null +++ b/src/services/static-analysis/qlty-init-helper.ts @@ -0,0 +1,55 @@ +import fs from 'fs'; +import path from 'path'; +import { LoggerService } from '../../common/logger'; +import { SpawnResult } from './spawn-helper'; + +const STATIC_ANALYSIS_LOG_CATEGORY = 'static-analysis'; + +/** + * Returns true when qlty's output indicates the project is not yet initialized + * and needs `qlty init` to be run before proceeding. + */ +export function requiresQltyInit(rawOutput: SpawnResult): boolean { + const combined = `${rawOutput.stdout}\n${rawOutput.stderr}`.toLowerCase(); + const hasInitHint = + combined.includes('not initialized') || + combined.includes('initialize') || + combined.includes('must run') || + combined.includes('must be set up') || + combined.includes('set up in this repository') || + combined.includes('no qlty config file found') || + combined.includes('try running'); + + return ( + (combined.includes('qlty init') || combined.includes('try: qlty init')) && hasInitHint + ); +} + +/** + * Returns true when `qlty init` has completed successfully, either by exit + * code 0 or by the presence of a qlty config file on disk (qlty init + * sometimes exits non-zero even after writing the config). + */ +export function qltyInitCompleted(repoPath: string, initOutput: SpawnResult): boolean { + if (initOutput.exitCode === 0) { + return true; + } + + const configCandidates = [ + path.join(repoPath, '.qlty', 'qlty.toml'), + path.join(repoPath, 'qlty.toml'), + ]; + const configExists = configCandidates.some((candidate) => fs.existsSync(candidate)); + if (configExists) { + LoggerService.warn( + 'Qlty init returned non-zero but config file exists; continuing as initialized.', + { + category: STATIC_ANALYSIS_LOG_CATEGORY, + data: { exitCode: initOutput.exitCode }, + } + ); + return true; + } + + return false; +} diff --git a/src/services/static-analysis/qlty-parser.ts b/src/services/static-analysis/qlty-parser.ts new file mode 100644 index 0000000..a4ef0ee --- /dev/null +++ b/src/services/static-analysis/qlty-parser.ts @@ -0,0 +1,388 @@ +import { LoggerService } from '../../common/logger'; +import { FindingCategory, FindingRunnerId, FindingSeverity, StaticAnalysisFinding } from './types'; + +const STATIC_ANALYSIS_LOG_CATEGORY = 'static-analysis'; + +export type QltyCategoryProfile = 'check' | 'smells'; + +type SarifReport = { + runs?: SarifRun[]; +}; + +type SarifRun = { + results?: SarifResult[]; + tool?: { + driver?: { + rules?: SarifRule[]; + }; + }; +}; + +type SarifRule = { + id?: unknown; + properties?: { + tags?: unknown; + category?: unknown; + }; +}; + +type SarifTaxon = { + id?: unknown; + name?: unknown; +}; + +type SarifResult = { + ruleId?: unknown; + level?: unknown; + message?: { + text?: unknown; + markdown?: unknown; + } | unknown; + locations?: Array<{ + physicalLocation?: { + artifactLocation?: { + uri?: unknown; + }; + region?: { + startLine?: unknown; + startColumn?: unknown; + }; + }; + }>; + properties?: { + category?: unknown; + tags?: unknown; + fixable?: unknown; + }; + fixes?: unknown; + taxa?: SarifTaxon[]; +}; + +export function parseQltyOutput( + stdout: string, + stderr: string, + profile: QltyCategoryProfile, + runnerId: FindingRunnerId +): StaticAnalysisFinding[] { + let findings = parseSarif(stdout, profile, runnerId); + if (findings.length === 0 && stderr.trim()) { + findings = parseSarif(stderr, profile, runnerId); + } + if (findings.length === 0) { + findings = parseText(`${stdout}\n${stderr}`, profile, runnerId); + } + return findings; +} + +function parseSarif( + output: string, + profile: QltyCategoryProfile, + runnerId: FindingRunnerId +): StaticAnalysisFinding[] { + if (!output || !output.trim()) { + return []; + } + + try { + const parsed = JSON.parse(output) as SarifReport; + if (!parsed || !Array.isArray(parsed.runs)) { + return []; + } + + const findings: StaticAnalysisFinding[] = []; + for (const run of parsed.runs) { + const rulesById = buildRuleIndex(run); + const runResults = Array.isArray(run.results) ? run.results : []; + for (const result of runResults) { + const mapped = mapSarifResultToFinding(result, rulesById, profile, runnerId); + if (mapped) { + findings.push(mapped); + } + } + } + return findings; + } catch (error) { + LoggerService.debug('Qlty SARIF parse failed; trying text fallback', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + data: { + error: error instanceof Error ? error.message : String(error), + }, + }); + return []; + } +} + +function parseText( + output: string, + profile: QltyCategoryProfile, + runnerId: FindingRunnerId +): StaticAnalysisFinding[] { + if (!output || !output.trim()) { + return []; + } + + const findings: StaticAnalysisFinding[] = []; + const lines = output.split(/\r?\n/); + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line) { + continue; + } + + const match = /^(.+?):(\d+)(?::(\d+))?\s+(.*)$/.exec(line); + if (!match) { + continue; + } + + const file = normalizeFilePath(match[1]); + const lineNumber = Number.parseInt(match[2], 10); + const columnNumber = match[3] ? Number.parseInt(match[3], 10) : 1; + let message = (match[4] || '').trim(); + + let severity: FindingSeverity = 'warning'; + const severityMatch = /^(error|warning|info|note)\b[:\s-]*(.*)$/i.exec(message); + if (severityMatch) { + severity = mapSeverity(severityMatch[1]); + message = (severityMatch[2] || '').trim() || message; + } + + let category: FindingCategory | null = null; + + const bracketCategoryMatch = /^\[([^\]]+)\]\s*(.*)$/.exec(message); + if (bracketCategoryMatch) { + category = normalizeCategoryToken(bracketCategoryMatch[1], profile); + message = (bracketCategoryMatch[2] || '').trim() || message; + } else { + const prefixCategoryMatch = /^([A-Za-z][A-Za-z0-9_-]{2,})\b[:\s-]+(.*)$/.exec(message); + if (prefixCategoryMatch) { + category = normalizeCategoryToken(prefixCategoryMatch[1], profile); + if (category) { + message = (prefixCategoryMatch[2] || '').trim() || message; + } + } + } + + const derivedCategory = category || deriveCategory('unknown.qlty.rule', message, [], profile); + findings.push({ + source: 'qlty', + runnerId, + file: file || 'unknown.file', + line: Number.isInteger(lineNumber) && lineNumber > 0 ? lineNumber : 1, + column: Number.isInteger(columnNumber) && columnNumber > 0 ? columnNumber : 1, + message: message || 'Qlty finding', + severity, + category: derivedCategory, + ruleId: `qlty.text.${derivedCategory}`, + }); + } + + return findings; +} + +function buildRuleIndex(run: SarifRun): Map { + const rules = run.tool?.driver?.rules; + const index = new Map(); + if (!Array.isArray(rules)) { + return index; + } + + for (const rule of rules) { + if (!rule || typeof rule !== 'object') { + continue; + } + const id = readNonEmptyString(rule.id); + if (id) { + index.set(id, rule); + } + } + return index; +} + +function mapSarifResultToFinding( + result: SarifResult, + rulesById: Map, + profile: QltyCategoryProfile, + runnerId: FindingRunnerId +): StaticAnalysisFinding | null { + if (!result || typeof result !== 'object') { + return null; + } + + const ruleId = readNonEmptyString(result.ruleId) || 'unknown.qlty.rule'; + const linkedRule = rulesById.get(ruleId); + + const firstLocation = Array.isArray(result.locations) ? result.locations[0] : undefined; + const file = normalizeFilePath( + readNonEmptyString(firstLocation?.physicalLocation?.artifactLocation?.uri) || 'unknown.file' + ); + const line = readPositiveInteger(firstLocation?.physicalLocation?.region?.startLine, 1); + const column = readPositiveInteger(firstLocation?.physicalLocation?.region?.startColumn, 1); + + const message = extractMessage(result.message) || 'Qlty finding'; + const severity = mapSeverity(readNonEmptyString(result.level) || ''); + + const categoryTokens: string[] = []; + const resultCategory = readNonEmptyString(result.properties?.category); + if (resultCategory) { + categoryTokens.push(resultCategory); + } + + categoryTokens.push(...readStringArray(result.properties?.tags)); + categoryTokens.push(...readStringArray(linkedRule?.properties?.tags)); + + for (const taxon of Array.isArray(result.taxa) ? result.taxa : []) { + const taxonId = readNonEmptyString(taxon?.id); + if (taxonId) { + categoryTokens.push(taxonId); + } + const taxonName = readNonEmptyString(taxon?.name); + if (taxonName) { + categoryTokens.push(taxonName); + } + } + + const linkedRuleCategory = readNonEmptyString(linkedRule?.properties?.category); + if (linkedRuleCategory) { + categoryTokens.push(linkedRuleCategory); + } + + const category = deriveCategory(ruleId, message, categoryTokens, profile); + const fixable = + (Array.isArray(result.fixes) && result.fixes.length > 0) || result.properties?.fixable === true; + + return { + source: 'qlty', + runnerId, + file: file || 'unknown.file', + line, + column, + message, + severity, + category, + ruleId, + fixable: fixable ? true : undefined, + }; +} + +function extractMessage(message: unknown): string | null { + if (typeof message === 'string' && message.trim()) { + return message.trim(); + } + if (!message || typeof message !== 'object') { + return null; + } + const messageText = readNonEmptyString((message as { text?: unknown }).text); + if (messageText) { + return messageText; + } + return readNonEmptyString((message as { markdown?: unknown }).markdown); +} + +function mapSeverity(level: string): FindingSeverity { + const normalized = level.trim().toLowerCase(); + if (normalized === 'error') { + return 'error'; + } + if (normalized === 'warning') { + return 'warning'; + } + return 'info'; +} + +function deriveCategory( + ruleId: string, + message: string, + categoryTokens: string[], + profile: QltyCategoryProfile +): FindingCategory { + for (const token of categoryTokens) { + const normalized = normalizeCategoryToken(token, profile); + if (normalized) { + return normalized; + } + } + + const haystack = `${ruleId} ${message}`.toLowerCase(); + const inferred = normalizeCategoryToken(haystack, profile); + return inferred || 'quality'; +} + +function normalizeCategoryToken( + token: string, + profile: QltyCategoryProfile +): FindingCategory | null { + const value = token.trim().toLowerCase(); + if (!value) { + return null; + } + + if (value.includes('security')) { + return 'security'; + } + if (value.includes('duplication') || value.includes('duplicate') || value.includes('clone')) { + return 'duplication'; + } + if ( + value.includes('complexity') || + value.includes('cyclomatic') || + value.includes('structure') || + value.includes('return-statements') + ) { + return 'complexity'; + } + if ( + value.includes('maintainability') || + value.includes('quality') || + value.includes('performance') || + value.includes('lint') + ) { + return 'quality'; + } + if (value.includes('style') || value.includes('format')) { + return 'style'; + } + if (value.includes('bug') || value.includes('correctness')) { + return 'bug'; + } + + return profile === 'smells' ? 'quality' : null; +} + +function normalizeFilePath(fileUri: string): string { + const trimmed = fileUri.trim(); + if (!trimmed) { + return 'unknown.file'; + } + + if (trimmed.startsWith('file://')) { + try { + const url = new URL(trimmed); + const decoded = decodeURIComponent(url.pathname); + const normalized = process.platform === 'win32' ? decoded.replace(/^\/([A-Za-z]:\/)/, '$1') : decoded; + return normalized.split('\\').join('/'); + } catch { + return trimmed.replace(/^file:\/\//, '').split('\\').join('/'); + } + } + + return trimmed.split('\\').join('/'); +} + +function readNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; +} + +function readStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function readPositiveInteger(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback; +} diff --git a/src/services/static-analysis/qlty-runner.ts b/src/services/static-analysis/qlty-runner.ts new file mode 100644 index 0000000..70c064c --- /dev/null +++ b/src/services/static-analysis/qlty-runner.ts @@ -0,0 +1,228 @@ +import { LoggerService } from '../../common/logger'; +import { qltyInitCompleted, requiresQltyInit } from './qlty-init-helper'; +import { SpawnResult, spawnAsync } from './spawn-helper'; +import { parseQltyOutput } from './qlty-parser'; +import { FindingSource, IToolRunner, StaticAnalysisFinding } from './types'; + +const STATIC_ANALYSIS_LOG_CATEGORY = 'static-analysis'; + +export class QltyRunner implements IToolRunner { + readonly name: FindingSource = 'qlty'; + private static readonly bootstrappedCheckRepos = new Set(); + + constructor(private readonly executablePath: string) {} + + async scan( + files: string[], + repoPath: string, + timeout: number + ): Promise<{ findings: StaticAnalysisFinding[]; rawOutput: SpawnResult }> { + if (files.length === 0) { + LoggerService.debug('Qlty scan skipped: no files provided', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { + findings: [], + rawOutput: { + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }, + }; + } + + const checkArgs = this.buildCheckArgs(files); + LoggerService.debug(`Running qlty on ${files.length} file(s)`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + data: { + executable: this.executablePath, + args: checkArgs, + cwd: repoPath, + }, + }); + + let rawOutput = await spawnAsync(this.executablePath, checkArgs, { + cwd: repoPath, + timeout, + }); + + if (rawOutput.timedOut) { + LoggerService.warn(`Qlty timed out after ${timeout}ms`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { findings: [], rawOutput }; + } + + if (requiresQltyInit(rawOutput)) { + LoggerService.info('Qlty project not initialized. Running `qlty init` and retrying once...', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + const initOutput = await spawnAsync(this.executablePath, ['init'], { + cwd: repoPath, + timeout, + }); + + if (initOutput.timedOut) { + LoggerService.warn(`Qlty init timed out after ${timeout}ms; skipping qlty scan`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { findings: [], rawOutput }; + } + + if (qltyInitCompleted(repoPath, initOutput)) { + rawOutput = await spawnAsync(this.executablePath, checkArgs, { + cwd: repoPath, + timeout, + }); + if (rawOutput.timedOut) { + LoggerService.warn(`Qlty timed out after ${timeout}ms`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { findings: [], rawOutput }; + } + } else { + LoggerService.warn('Qlty init failed; skipping qlty scan.', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + data: { + exitCode: initOutput.exitCode, + }, + }); + return { findings: [], rawOutput }; + } + } + + let findings = parseQltyOutput(rawOutput.stdout, rawOutput.stderr, 'check', 'qlty-check'); + + if (this.shouldAttemptCheckBootstrap(repoPath, rawOutput, findings.length)) { + LoggerService.info( + 'Qlty check returned no findings; attempting `qlty install` bootstrap and retrying once...', + { category: STATIC_ANALYSIS_LOG_CATEGORY } + ); + + const bootstrapped = await this.bootstrapChecks(repoPath, timeout); + if (bootstrapped) { + const retryOutput = await spawnAsync(this.executablePath, checkArgs, { + cwd: repoPath, + timeout, + }); + if (retryOutput.timedOut) { + LoggerService.warn(`Qlty timed out after ${timeout}ms on post-bootstrap retry`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { findings: [], rawOutput: retryOutput }; + } + + findings = parseQltyOutput( + retryOutput.stdout, + retryOutput.stderr, + 'check', + 'qlty-check' + ); + rawOutput = retryOutput; + } + } + + LoggerService.debug(`Qlty parsed ${findings.length} finding(s)`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + data: { + exitCode: rawOutput.exitCode, + }, + }); + + return { findings, rawOutput }; + } + + private buildCheckArgs(files: string[]): string[] { + return ['check', '--sarif', '--no-progress', ...files]; + } + + private shouldAttemptCheckBootstrap( + repoPath: string, + rawOutput: SpawnResult, + findingsCount: number + ): boolean { + if (findingsCount > 0) { + return false; + } + if (rawOutput.timedOut) { + return false; + } + const key = this.bootstrapKey(repoPath); + return !QltyRunner.bootstrappedCheckRepos.has(key); + } + + private async bootstrapChecks(repoPath: string, timeout: number): Promise { + let installOutput = await spawnAsync(this.executablePath, ['install'], { + cwd: repoPath, + timeout, + }); + + if (installOutput.timedOut) { + LoggerService.warn(`Qlty install timed out after ${timeout}ms; skipping check bootstrap`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return false; + } + + if (requiresQltyInit(installOutput)) { + LoggerService.info( + 'Qlty install requires project initialization. Running `qlty init` and retrying install once...', + { category: STATIC_ANALYSIS_LOG_CATEGORY } + ); + const initOutput = await spawnAsync(this.executablePath, ['init'], { + cwd: repoPath, + timeout, + }); + + if (initOutput.timedOut) { + LoggerService.warn(`Qlty init timed out after ${timeout}ms during bootstrap`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return false; + } + + if (!qltyInitCompleted(repoPath, initOutput)) { + LoggerService.warn('Qlty init failed during check bootstrap; skipping retry.', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + data: { + exitCode: initOutput.exitCode, + }, + }); + return false; + } + + installOutput = await spawnAsync(this.executablePath, ['install'], { + cwd: repoPath, + timeout, + }); + if (installOutput.timedOut) { + LoggerService.warn(`Qlty install timed out after ${timeout}ms on retry`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return false; + } + } + + if (installOutput.exitCode !== 0) { + LoggerService.warn('Qlty install failed during check bootstrap; continuing without retry.', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + data: { + exitCode: installOutput.exitCode, + }, + }); + return false; + } + + LoggerService.info('Qlty check bootstrap completed via `qlty install`.', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + QltyRunner.bootstrappedCheckRepos.add(this.bootstrapKey(repoPath)); + return true; + } + + private bootstrapKey(repoPath: string): string { + return `${this.executablePath}::${repoPath}`; + } + +} diff --git a/src/services/static-analysis/qlty-smells-runner.ts b/src/services/static-analysis/qlty-smells-runner.ts new file mode 100644 index 0000000..ef52ac6 --- /dev/null +++ b/src/services/static-analysis/qlty-smells-runner.ts @@ -0,0 +1,122 @@ +import { LoggerService } from '../../common/logger'; +import { qltyInitCompleted, requiresQltyInit } from './qlty-init-helper'; +import { SpawnResult, spawnAsync } from './spawn-helper'; +import { parseQltyOutput } from './qlty-parser'; +import { FindingSource, IToolRunner, StaticAnalysisFinding } from './types'; + +const STATIC_ANALYSIS_LOG_CATEGORY = 'static-analysis'; + +export interface QltySmellsRunnerOptions { + includeDuplication?: boolean; +} + +export class QltySmellsRunner implements IToolRunner { + readonly name: FindingSource = 'qlty'; + + constructor( + private readonly executablePath: string, + private readonly options: QltySmellsRunnerOptions = {} + ) {} + + async scan( + files: string[], + repoPath: string, + timeout: number + ): Promise<{ findings: StaticAnalysisFinding[]; rawOutput: SpawnResult }> { + if (files.length === 0) { + LoggerService.debug('Qlty smells scan skipped: no files provided', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { + findings: [], + rawOutput: { + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }, + }; + } + + const smellsArgs = this.buildSmellsArgs(files); + LoggerService.debug(`Running qlty smells on ${files.length} file(s)`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + data: { + executable: this.executablePath, + args: smellsArgs, + cwd: repoPath, + }, + }); + + let rawOutput = await spawnAsync(this.executablePath, smellsArgs, { + cwd: repoPath, + timeout, + }); + + if (rawOutput.timedOut) { + LoggerService.warn(`Qlty smells timed out after ${timeout}ms`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { findings: [], rawOutput }; + } + + if (requiresQltyInit(rawOutput)) { + LoggerService.info('Qlty project not initialized. Running `qlty init` and retrying smells once...', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + const initOutput = await spawnAsync(this.executablePath, ['init'], { + cwd: repoPath, + timeout, + }); + + if (initOutput.timedOut) { + LoggerService.warn(`Qlty init timed out after ${timeout}ms; skipping qlty smells scan`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { findings: [], rawOutput }; + } + + if (qltyInitCompleted(repoPath, initOutput)) { + rawOutput = await spawnAsync(this.executablePath, smellsArgs, { + cwd: repoPath, + timeout, + }); + if (rawOutput.timedOut) { + LoggerService.warn(`Qlty smells timed out after ${timeout}ms`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { findings: [], rawOutput }; + } + } else { + LoggerService.warn('Qlty init failed; skipping qlty smells scan.', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + data: { + exitCode: initOutput.exitCode, + }, + }); + return { findings: [], rawOutput }; + } + } + + const findings = parseQltyOutput(rawOutput.stdout, rawOutput.stderr, 'smells', 'qlty-smells'); + + LoggerService.debug(`Qlty smells parsed ${findings.length} finding(s)`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + data: { + exitCode: rawOutput.exitCode, + }, + }); + + return { findings, rawOutput }; + } + + private buildSmellsArgs(files: string[]): string[] { + const args = ['smells', '--sarif', '--quiet', '--no-snippets']; + if (this.options.includeDuplication === false) { + args.push('--no-duplication'); + } + args.push(...files); + return args; + } + +} diff --git a/src/services/static-analysis/semgrep-runner.ts b/src/services/static-analysis/semgrep-runner.ts new file mode 100644 index 0000000..96d0fb0 --- /dev/null +++ b/src/services/static-analysis/semgrep-runner.ts @@ -0,0 +1,275 @@ +import { LoggerService } from '../../common/logger'; +import { SpawnResult, spawnAsync } from './spawn-helper'; +import { + FindingCategory, + FindingSeverity, + FindingSource, + IToolRunner, + StaticAnalysisFinding, +} from './types'; + +const STATIC_ANALYSIS_LOG_CATEGORY = 'static-analysis'; + +type SemgrepMetadata = { + category?: unknown; + cwe?: unknown; + owasp?: unknown; +}; + +type SemgrepResult = { + check_id?: unknown; + path?: unknown; + start?: { + line?: unknown; + col?: unknown; + }; + extra?: { + message?: unknown; + severity?: unknown; + metadata?: SemgrepMetadata; + }; +}; + +type SemgrepOutput = { + results?: SemgrepResult[]; +}; + +export class SemgrepRunner implements IToolRunner { + readonly name: FindingSource = 'semgrep'; + + constructor( + private readonly executablePath: string, + private readonly rules: string[] + ) {} + + async scan( + files: string[], + repoPath: string, + timeout: number + ): Promise<{ findings: StaticAnalysisFinding[]; rawOutput: SpawnResult }> { + if (files.length === 0) { + LoggerService.debug('Semgrep scan skipped: no files provided', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { + findings: [], + rawOutput: { + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }, + }; + } + + const args = this.buildArgs(files); + LoggerService.debug(`Running semgrep on ${files.length} file(s)`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + data: { + executable: this.executablePath, + args, + cwd: repoPath, + }, + }); + + const rawOutput = await spawnAsync(this.executablePath, args, { + cwd: repoPath, + timeout, + }); + + if (rawOutput.timedOut) { + LoggerService.warn(`Semgrep timed out after ${timeout}ms`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { findings: [], rawOutput }; + } + + const findings = this.parseOutput(rawOutput.stdout); + LoggerService.debug(`Semgrep parsed ${findings.length} finding(s)`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + data: { + exitCode: rawOutput.exitCode, + }, + }); + + return { findings, rawOutput }; + } + + private buildArgs(files: string[]): string[] { + const args = ['scan', '--json', '--quiet', '--oss-only']; + + for (const rule of this.rules) { + if (rule && rule.trim()) { + args.push('--config', rule.trim()); + } + } + + args.push(...files); + return args; + } + + private parseOutput(jsonString: string): StaticAnalysisFinding[] { + if (!jsonString || !jsonString.trim()) { + return []; + } + + try { + const parsed = JSON.parse(jsonString) as SemgrepOutput; + if (!parsed || !Array.isArray(parsed.results)) { + LoggerService.warn('Semgrep output missing results array; skipping parse', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return []; + } + + const findings: StaticAnalysisFinding[] = []; + for (const result of parsed.results) { + const mapped = this.mapResultToFinding(result); + if (mapped) { + findings.push(mapped); + } + } + + return findings; + } catch (error) { + LoggerService.warn('Failed to parse Semgrep JSON output; returning empty findings', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + data: { + error: error instanceof Error ? error.message : String(error), + }, + }); + return []; + } + } + + private mapResultToFinding(result: SemgrepResult): StaticAnalysisFinding | null { + if (!result || typeof result !== 'object') { + return null; + } + + const checkId = this.readNonEmptyString(result.check_id) || 'unknown.semgrep.rule'; + const file = this.readNonEmptyString(result.path) || 'unknown.file'; + const line = this.readPositiveInteger(result.start?.line, 1); + const column = this.readPositiveInteger(result.start?.col, 1); + + const message = + this.readNonEmptyString(result.extra?.message) || 'Semgrep finding'; + const severity = this.mapSeverity(this.readNonEmptyString(result.extra?.severity) || ''); + const metadata = result.extra?.metadata; + const category = this.deriveCategory(checkId, metadata); + + const cwe = this.readStringArray(metadata?.cwe); + const owasp = this.readStringArray(metadata?.owasp); + + return { + source: 'semgrep', + runnerId: 'semgrep', + file, + line, + column, + message, + severity, + category, + ruleId: checkId, + cwe: cwe.length > 0 ? cwe : undefined, + owasp: owasp.length > 0 ? owasp : undefined, + }; + } + + private deriveCategory(checkId: string, metadata?: SemgrepMetadata): FindingCategory { + const metadataCategory = this.normalizeCategory( + this.readNonEmptyString(metadata?.category) + ); + if (metadataCategory) { + return metadataCategory; + } + + const normalizedCheckId = checkId.toLowerCase(); + if ( + normalizedCheckId.includes('security') || + normalizedCheckId.includes('audit') + ) { + return 'security'; + } + if ( + normalizedCheckId.includes('correctness') || + normalizedCheckId.includes('bug') + ) { + return 'bug'; + } + if ( + normalizedCheckId.includes('style') || + normalizedCheckId.includes('format') + ) { + return 'style'; + } + if (normalizedCheckId.includes('performance')) { + return 'quality'; + } + + return 'security'; + } + + private normalizeCategory(category: string | null): FindingCategory | null { + if (!category) { + return null; + } + + const value = category.trim().toLowerCase(); + switch (value) { + case 'security': + return 'security'; + case 'quality': + case 'maintainability': + case 'performance': + return 'quality'; + case 'complexity': + return 'complexity'; + case 'duplication': + return 'duplication'; + case 'style': + case 'format': + case 'formatting': + return 'style'; + case 'bug': + case 'correctness': + return 'bug'; + default: + return null; + } + } + + private mapSeverity(semgrepSeverity: string): FindingSeverity { + switch (semgrepSeverity.trim().toUpperCase()) { + case 'ERROR': + return 'error'; + case 'INFO': + return 'info'; + case 'WARNING': + return 'warning'; + default: + return 'warning'; + } + } + + private readNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; + } + + private readPositiveInteger(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isInteger(value) && value > 0 + ? value + : fallback; + } + + private readStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .filter((entry): entry is string => typeof entry === 'string') + .map((entry) => entry.trim()) + .filter(Boolean); + } +} diff --git a/src/services/static-analysis/spawn-helper.ts b/src/services/static-analysis/spawn-helper.ts new file mode 100644 index 0000000..3e2d323 --- /dev/null +++ b/src/services/static-analysis/spawn-helper.ts @@ -0,0 +1,95 @@ +import { spawn } from 'child_process'; + +export interface SpawnResult { + stdout: string; + stderr: string; + exitCode: number | null; + timedOut: boolean; +} + +const DEFAULT_TIMEOUT = 60_000; +const DEFAULT_MAX_BUFFER = 10 * 1024 * 1024; // 10MB + +/** + * Execute a command asynchronously with timeout. + * Kills process on timeout. Always resolves (never rejects) β€” + * callers decide if non-zero exit code is an error. + */ +export function spawnAsync( + command: string, + args: string[], + options: { + cwd?: string; + timeout?: number; + maxBuffer?: number; + } = {} +): Promise { + const { cwd, timeout = DEFAULT_TIMEOUT, maxBuffer = DEFAULT_MAX_BUFFER } = options; + + return new Promise((resolve) => { + let stdout = ''; + let stderr = ''; + let timedOut = false; + let killed = false; + let closed = false; + + const child = spawn(command, args, { + cwd, + stdio: 'pipe', + // No shell β€” explicit args prevent command injection + }); + + child.stdout.on('data', (chunk: Buffer) => { + if (stdout.length < maxBuffer) { + stdout += chunk.toString(); + } + }); + + child.stderr.on('data', (chunk: Buffer) => { + if (stderr.length < maxBuffer) { + stderr += chunk.toString(); + } + }); + + let forceKillTimer: NodeJS.Timeout | undefined; + const timeoutTimer = setTimeout(() => { + timedOut = true; + killed = true; + child.kill('SIGTERM'); + // Force kill after 5s if SIGTERM doesn't work + forceKillTimer = setTimeout(() => { + if (!closed) { + child.kill('SIGKILL'); + } + }, 5000); + }, timeout); + + child.on('close', (code) => { + closed = true; + clearTimeout(timeoutTimer); + if (forceKillTimer) { + clearTimeout(forceKillTimer); + } + resolve({ + stdout: stdout.slice(0, maxBuffer), + stderr: stderr.slice(0, maxBuffer), + exitCode: killed ? null : code, + timedOut, + }); + }); + + child.on('error', (err) => { + closed = true; + clearTimeout(timeoutTimer); + if (forceKillTimer) { + clearTimeout(forceKillTimer); + } + resolve({ + stdout, + stderr: stderr || err.message, + exitCode: null, + timedOut: false, + }); + }); + }); +} diff --git a/src/services/static-analysis/static-analysis.service.ts b/src/services/static-analysis/static-analysis.service.ts new file mode 100644 index 0000000..ab16458 --- /dev/null +++ b/src/services/static-analysis/static-analysis.service.ts @@ -0,0 +1,666 @@ +import { LoggerService } from '../../common/logger'; +import { FileScopeResolver } from './file-scope-resolver'; +import { ToolInstaller } from './tool-installer'; +import { buildToolRunnerPlans, ToolRunnerPlan } from './tool-runner-registry'; +import { + findingMatchesChangedLines, + parseChangedLineRangesFromUnifiedDiff, +} from './changed-lines-scope'; +import { + FindingSeverity, + IToolRunner, + RawToolOutput, + SEVERITY_RANK, + StaticAnalysisConfig, + StaticAnalysisFinding, + StaticAnalysisSummary, + ToolAvailability, +} from './types'; + +const STATIC_ANALYSIS_LOG_CATEGORY = 'static-analysis'; +const ALWAYS_EXCLUDED_TEST_PATTERNS = [ + '**/__tests__/**', + '**/test/**', + '**/tests/**', + '**/*.test.*', + '**/*.spec.*', + '**/*_test.*', + '**/*_spec.*', +]; +const STATIC_ANALYSIS_SETUP_HINT = + "Run 'codewave config --init' and choose 'Yes' for static analyzer installation to enable Semgrep/Qlty."; +const STATIC_ANALYSIS_TOOLS_MISSING_WARNING = + 'Static analysis requested, but tools are not installed. Continuing without unavailable analyzers.'; + +export type StaticAnalysisOutputMode = 'single' | 'batch' | 'mcp'; + +export interface StaticAnalysisResult { + summary: StaticAnalysisSummary; + rawOutputs: RawToolOutput; +} + +export interface StaticAnalysisInput { + commitDiff?: string; +} + +interface PlanExecutionResult { + plan: ToolRunnerPlan; + elapsedMs: number; + findings: StaticAnalysisFinding[]; + rawOutput?: { + stdout: string; + stderr: string; + exitCode: number | null; + timedOut: boolean; + }; + error?: unknown; +} + +export class StaticAnalysisService { + static async analyze( + filesChanged: string[], + repoPath: string, + config: StaticAnalysisConfig, + input: StaticAnalysisInput = {} + ): Promise { + const startTime = Date.now(); + const warnings: string[] = []; + const rawOutputs: RawToolOutput = {}; + let toolAvailabilityMs = 0; + let scopeResolutionMs = 0; + let postProcessMs = 0; + let effectiveAvailability = { semgrep: false, qlty: false }; + let availability: ToolAvailability = { + semgrep: { available: false, executable: null, installedNow: false }, + qlty: { available: false, executable: null, installedNow: false }, + }; + + if (!config.enabled) { + const totalMs = Date.now() - startTime; + return { + summary: this.buildEmptySummary({ + availability: effectiveAvailability, + totalMs, + toolAvailabilityMs, + scopeResolutionMs, + postProcessMs, + overheadMs: totalMs, + warnings: ['Static analysis disabled by configuration.'], + }), + rawOutputs, + }; + } + + const availabilityStart = Date.now(); + try { + if (config.autoInstall) { + warnings.push( + `staticAnalysis.autoInstall=true is deprecated and ignored during evaluation. Runtime auto-install is disabled. ${STATIC_ANALYSIS_SETUP_HINT}` + ); + } + + availability = await ToolInstaller.ensureAvailable(false, config.autoInstallSystemDeps); + } catch (error) { + warnings.push( + `Tool availability check failed: ${error instanceof Error ? error.message : String(error)}` + ); + } finally { + toolAvailabilityMs = Date.now() - availabilityStart; + } + + const toolRunnerPlans = this.getToolRunnerPlans(config, availability); + + effectiveAvailability = { + semgrep: toolRunnerPlans.some((plan) => plan.source === 'semgrep' && plan.runnable), + qlty: toolRunnerPlans.some((plan) => plan.source === 'qlty' && plan.runnable), + }; + + for (const plan of toolRunnerPlans) { + warnings.push(...plan.warnings); + } + + const requestedButUnavailable = + (config.semgrep.enabled && !availability.semgrep.available) || + (config.qlty.enabled && !availability.qlty.available); + if (requestedButUnavailable) { + warnings.push(STATIC_ANALYSIS_TOOLS_MISSING_WARNING, STATIC_ANALYSIS_SETUP_HINT); + } + + const scopeStart = Date.now(); + const scope = FileScopeResolver.resolve( + filesChanged, + repoPath, + this.mergeExcludedPaths(config.excludedPaths) + ); + scopeResolutionMs = Date.now() - scopeStart; + if (scope.scanFiles.length === 0) { + warnings.push('No eligible commit files for static analysis after scope resolution.'); + const totalMs = Date.now() - startTime; + return { + summary: this.buildEmptySummary({ + availability: effectiveAvailability, + skippedFiles: scope, + totalMs, + toolAvailabilityMs, + scopeResolutionMs, + postProcessMs, + overheadMs: Math.max(0, totalMs - toolAvailabilityMs - scopeResolutionMs), + warnings, + }), + rawOutputs, + }; + } + + const runnablePlans = toolRunnerPlans.filter((plan) => plan.runnable && plan.createRunner); + + if (runnablePlans.length === 0) { + warnings.push('No static analysis runners available to execute.'); + const totalMs = Date.now() - startTime; + return { + summary: this.buildEmptySummary({ + availability: effectiveAvailability, + skippedFiles: scope, + totalMs, + toolAvailabilityMs, + scopeResolutionMs, + postProcessMs, + overheadMs: Math.max(0, totalMs - toolAvailabilityMs - scopeResolutionMs), + warnings, + }), + rawOutputs, + }; + } + + let semgrepMs = 0; + let qltyMs = 0; + let sawSemgrep = false; + let sawQlty = false; + const allFindings: StaticAnalysisFinding[] = []; + const semgrepPlan = runnablePlans.find((plan) => plan.id === 'semgrep') ?? null; + const qltyCheckPlan = runnablePlans.find((plan) => plan.id === 'qlty-check') ?? null; + const qltySmellsPlan = runnablePlans.find((plan) => plan.id === 'qlty-smells') ?? null; + + const semgrepExecutionPromise = semgrepPlan + ? this.executePlan(semgrepPlan, scope.scanFiles, repoPath) + : Promise.resolve(null); + + const qltyExecutionPromise = this.executeQltyPipeline( + qltyCheckPlan, + qltySmellsPlan, + scope.scanFiles, + repoPath + ); + + const [semgrepExecution, qltyExecutions] = await Promise.all([ + semgrepExecutionPromise, + qltyExecutionPromise, + ]); + + const orderedExecutions = [semgrepExecution, ...qltyExecutions].filter( + (execution): execution is PlanExecutionResult => execution !== null + ); + + for (const execution of orderedExecutions) { + const { plan, elapsedMs } = execution; + + if (execution.error) { + warnings.push( + `${plan.id} scan failed: ${ + execution.error instanceof Error ? execution.error.message : String(execution.error) + }` + ); + continue; + } + + allFindings.push(...execution.findings); + + if (plan.source === 'semgrep') { + sawSemgrep = true; + semgrepMs += elapsedMs; + rawOutputs.semgrep = execution.rawOutput; + const semgrepStdout = execution.rawOutput?.stdout || ''; + warnings.push(...this.extractSemgrepEngineWarnings(semgrepStdout)); + } else if (plan.id === 'qlty-check') { + sawQlty = true; + qltyMs += elapsedMs; + rawOutputs.qlty = execution.rawOutput; + } else if (plan.id === 'qlty-smells') { + sawQlty = true; + qltyMs += elapsedMs; + rawOutputs.qltySmells = execution.rawOutput; + } + } + + const postProcessStart = Date.now(); + const changedLineFiltered = this.filterByChangedLines( + allFindings, + repoPath, + input.commitDiff, + warnings + ); + const deduplicated = this.deduplicate(changedLineFiltered); + const severityFiltered = this.filterByMinSeverity(deduplicated, config.minSeverity); + const sorted = this.sortFindings(severityFiltered); + const totalBeforeCap = sorted.length; + const findings = sorted.slice(0, config.maxFindings); + const truncated = totalBeforeCap > config.maxFindings; + + if (truncated) { + warnings.push(`Showing ${findings.length} of ${totalBeforeCap} findings (cap: ${config.maxFindings}).`); + } + postProcessMs = Date.now() - postProcessStart; + const totalMs = Date.now() - startTime; + const semgrepTiming = sawSemgrep ? semgrepMs : null; + const qltyTiming = sawQlty ? qltyMs : null; + const runnerMs = Math.max(semgrepTiming || 0, qltyTiming || 0); + const overheadMs = Math.max( + 0, + totalMs - toolAvailabilityMs - scopeResolutionMs - postProcessMs - runnerMs + ); + + const summary: StaticAnalysisSummary = { + available: effectiveAvailability, + timing: { + semgrepMs: semgrepTiming, + qltyMs: qltyTiming, + toolAvailabilityMs, + scopeResolutionMs, + postProcessMs, + overheadMs, + totalMs, + }, + counts: this.buildCounts(findings), + findings, + truncated, + totalBeforeCap, + skippedFiles: { + missing: scope.skippedMissing, + excluded: scope.skippedExcluded, + unsupported: scope.skippedUnsupported, + }, + warnings: this.normalizeWarnings(warnings), + }; + + return { summary, rawOutputs }; + } + + static logOutput(summary: StaticAnalysisSummary, outputMode: StaticAnalysisOutputMode): void { + if (summary.counts.total === 0) { + if (outputMode === 'single' || summary.warnings.length > 0) { + LoggerService.info('Static analysis completed with no findings.', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + } + + for (const warning of summary.warnings) { + LoggerService.warn(warning, { category: STATIC_ANALYSIS_LOG_CATEGORY }); + } + return; + } + + LoggerService.info( + `Static analysis found ${summary.counts.total} finding(s) (${summary.counts.errors} errors, ${summary.counts.warnings} warnings, ${summary.counts.info} info).`, + { + category: STATIC_ANALYSIS_LOG_CATEGORY, + data: { + outputMode, + timingMs: summary.timing.totalMs, + }, + } + ); + + const byRunnerSummary = Object.entries(summary.counts.byRunner) + .map(([runnerId, count]) => `${runnerId}=${count}`) + .join(', '); + if (byRunnerSummary) { + LoggerService.info(`Static analysis findings by runner: ${byRunnerSummary}`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + } + + for (const warning of summary.warnings) { + LoggerService.warn(warning, { category: STATIC_ANALYSIS_LOG_CATEGORY }); + } + + if (outputMode !== 'single') { + return; + } + + for (const finding of summary.findings) { + const runnerLabel = + finding.runnerId === finding.source + ? finding.source + : `${finding.source}:${finding.runnerId}`; + LoggerService.info( + `[${finding.severity}][${finding.category}][${runnerLabel}] ${finding.file}:${finding.line}:${finding.column} ${finding.ruleId} - ${finding.message}`, + { category: STATIC_ANALYSIS_LOG_CATEGORY } + ); + } + } + + private static buildEmptySummary(args: { + availability: { + semgrep: boolean; + qlty: boolean; + }; + skippedFiles?: { + scanFiles: string[]; + skippedMissing: string[]; + skippedExcluded: string[]; + skippedUnsupported: string[]; + }; + totalMs: number; + toolAvailabilityMs: number; + scopeResolutionMs: number; + postProcessMs: number; + overheadMs: number; + warnings: string[]; + }): StaticAnalysisSummary { + return { + available: args.availability, + timing: { + semgrepMs: null, + qltyMs: null, + toolAvailabilityMs: args.toolAvailabilityMs, + scopeResolutionMs: args.scopeResolutionMs, + postProcessMs: args.postProcessMs, + overheadMs: args.overheadMs, + totalMs: args.totalMs, + }, + counts: { + total: 0, + errors: 0, + warnings: 0, + info: 0, + byCategory: {}, + byFile: {}, + byRunner: { + semgrep: 0, + 'qlty-check': 0, + 'qlty-smells': 0, + }, + }, + findings: [], + truncated: false, + totalBeforeCap: 0, + skippedFiles: { + missing: args.skippedFiles?.skippedMissing || [], + excluded: args.skippedFiles?.skippedExcluded || [], + unsupported: args.skippedFiles?.skippedUnsupported || [], + }, + warnings: this.normalizeWarnings(args.warnings), + }; + } + + private static extractSemgrepEngineWarnings(stdout: string): string[] { + if (!stdout || !stdout.trim()) { + return []; + } + + try { + const parsed = JSON.parse(stdout) as { + errors?: Array<{ + message?: unknown; + rule_id?: unknown; + path?: unknown; + }>; + }; + + if (!Array.isArray(parsed.errors) || parsed.errors.length === 0) { + return []; + } + + let suppressedProEngineWarnings = 0; + const actionableErrors = parsed.errors.filter((entry) => { + const message = this.readSingleLineString(entry.message) || ''; + if (this.isProEngineOnlySemgrepWarning(message)) { + suppressedProEngineWarnings += 1; + return false; + } + return true; + }); + + if (suppressedProEngineWarnings > 0) { + LoggerService.debug( + `Suppressed ${suppressedProEngineWarnings} Semgrep Pro-engine-only warning(s).`, + { category: STATIC_ANALYSIS_LOG_CATEGORY } + ); + } + + if (actionableErrors.length === 0) { + return []; + } + + const maxWarnings = 5; + const warnings = actionableErrors.slice(0, maxWarnings).map((entry) => { + const message = this.readSingleLineString(entry.message) || 'Unknown Semgrep engine warning.'; + const ruleId = this.readSingleLineString(entry.rule_id); + const filePath = this.readSingleLineString(entry.path); + + const parts = ['Semgrep engine warning']; + if (ruleId) { + parts.push(`rule: ${ruleId}`); + } + if (filePath) { + parts.push(`file: ${filePath}`); + } + + return `${parts.join(' | ')} | ${message}`; + }); + + if (actionableErrors.length > maxWarnings) { + warnings.push( + `Semgrep reported ${actionableErrors.length - maxWarnings} additional engine warning(s).` + ); + } + + return warnings; + } catch { + return []; + } + } + + private static isProEngineOnlySemgrepWarning(message: string): boolean { + const normalized = message.toLowerCase(); + return ( + normalized.includes('only supported in the pro engine') || + normalized.includes('requires semgrep pro engine') || + normalized.includes('only available in the pro engine') + ); + } + + private static readSingleLineString(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + + return trimmed.replace(/\s+/g, ' '); + } + + private static normalizeWarnings(warnings: string[]): string[] { + return Array.from(new Set(warnings.map((warning) => warning.trim()).filter(Boolean))); + } + + private static mergeExcludedPaths(configExcludedPaths: string[]): string[] { + const merged = new Set(); + for (const pattern of configExcludedPaths || []) { + if (typeof pattern === 'string' && pattern.trim()) { + merged.add(pattern.trim()); + } + } + for (const pattern of ALWAYS_EXCLUDED_TEST_PATTERNS) { + merged.add(pattern); + } + return Array.from(merged); + } + + private static getToolRunnerPlans(config: StaticAnalysisConfig, availability: ToolAvailability) { + return buildToolRunnerPlans(config, availability); + } + + private static async executePlan( + plan: ToolRunnerPlan, + scanFiles: string[], + repoPath: string + ): Promise { + const runner: IToolRunner = plan.createRunner!(); + const toolStart = Date.now(); + try { + const result = await runner.scan(scanFiles, repoPath, plan.timeoutMs); + return { + plan, + elapsedMs: Date.now() - toolStart, + findings: result.findings, + rawOutput: result.rawOutput, + }; + } catch (error) { + return { + plan, + elapsedMs: Date.now() - toolStart, + findings: [], + error, + }; + } + } + + private static async executeQltyPipeline( + qltyCheckPlan: ToolRunnerPlan | null, + qltySmellsPlan: ToolRunnerPlan | null, + scanFiles: string[], + repoPath: string + ): Promise { + const executions: PlanExecutionResult[] = []; + + if (qltyCheckPlan) { + executions.push(await this.executePlan(qltyCheckPlan, scanFiles, repoPath)); + } + + if (qltySmellsPlan) { + executions.push(await this.executePlan(qltySmellsPlan, scanFiles, repoPath)); + } + + return executions; + } + + private static filterByChangedLines( + findings: StaticAnalysisFinding[], + repoPath: string, + commitDiff: string | undefined, + warnings: string[] + ): StaticAnalysisFinding[] { + if (findings.length === 0) { + return findings; + } + + if (!commitDiff || !commitDiff.trim()) { + warnings.push('Changed-line filtering skipped: commit diff was not provided.'); + return findings; + } + + const parsed = parseChangedLineRangesFromUnifiedDiff(commitDiff); + if (!parsed.parseable) { + warnings.push('Changed-line filtering skipped: commit diff could not be parsed.'); + return findings; + } + + const filtered = findings.filter((finding) => + findingMatchesChangedLines(finding.file, finding.line, parsed.rangesByFile, repoPath) + ); + const droppedCount = findings.length - filtered.length; + + if (droppedCount > 0) { + warnings.push( + `Changed-line filtering dropped ${droppedCount} finding(s) without a changed line match.` + ); + } + + return filtered; + } + + private static deduplicate(findings: StaticAnalysisFinding[]): StaticAnalysisFinding[] { + const unique = new Map(); + for (const finding of findings) { + const key = [ + finding.runnerId, + finding.source, + finding.file, + finding.line, + finding.column, + finding.category, + finding.ruleId, + finding.message, + ].join('|'); + + if (!unique.has(key)) { + unique.set(key, finding); + } + } + return Array.from(unique.values()); + } + + private static filterByMinSeverity( + findings: StaticAnalysisFinding[], + minSeverity: FindingSeverity + ): StaticAnalysisFinding[] { + const minRank = SEVERITY_RANK[minSeverity]; + return findings.filter((finding) => SEVERITY_RANK[finding.severity] >= minRank); + } + + private static sortFindings(findings: StaticAnalysisFinding[]): StaticAnalysisFinding[] { + return [...findings].sort((a, b) => { + const bySeverity = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]; + if (bySeverity !== 0) { + return bySeverity; + } + + const byFile = a.file.localeCompare(b.file); + if (byFile !== 0) { + return byFile; + } + + const byLine = a.line - b.line; + if (byLine !== 0) { + return byLine; + } + + return a.column - b.column; + }); + } + + private static buildCounts(findings: StaticAnalysisFinding[]): StaticAnalysisSummary['counts'] { + const counts = { + total: findings.length, + errors: 0, + warnings: 0, + info: 0, + byCategory: {} as Record, + byFile: {} as Record, + byRunner: { + semgrep: 0, + 'qlty-check': 0, + 'qlty-smells': 0, + } as Record<'semgrep' | 'qlty-check' | 'qlty-smells', number>, + }; + + for (const finding of findings) { + if (finding.severity === 'error') { + counts.errors += 1; + } else if (finding.severity === 'warning') { + counts.warnings += 1; + } else { + counts.info += 1; + } + + counts.byCategory[finding.category] = (counts.byCategory[finding.category] || 0) + 1; + counts.byFile[finding.file] = (counts.byFile[finding.file] || 0) + 1; + counts.byRunner[finding.runnerId] = (counts.byRunner[finding.runnerId] || 0) + 1; + } + + return counts; + } +} diff --git a/src/services/static-analysis/tool-discovery.ts b/src/services/static-analysis/tool-discovery.ts new file mode 100644 index 0000000..1a99369 --- /dev/null +++ b/src/services/static-analysis/tool-discovery.ts @@ -0,0 +1,88 @@ +import fs from 'fs'; +import * as childProcess from 'child_process'; + +export type CommandRunner = ( + command: string, + args: string[] +) => childProcess.SpawnSyncReturns; + +export interface ToolInstalledInfo { + installed: boolean; + version?: string; +} + +export interface ToolDiscoveryDependencies { + runCommand: CommandRunner; + isToolInstalled: (executablePath: string) => ToolInstalledInfo; + getManagedExecutablePath: (toolName: string) => string; +} + +export function findExecutable(toolName: string, deps: ToolDiscoveryDependencies): string | null { + const systemExecutable = findInSystemPath(toolName, deps.runCommand); + if (systemExecutable && deps.isToolInstalled(systemExecutable).installed) { + return systemExecutable; + } + + const managedExecutable = deps.getManagedExecutablePath(toolName); + if (fileExists(managedExecutable) && deps.isToolInstalled(managedExecutable).installed) { + return managedExecutable; + } + + return null; +} + +export function isToolInstalled(executablePath: string): ToolInstalledInfo { + const result = childProcess.spawnSync(executablePath, ['--version'], { + stdio: 'pipe', + encoding: 'utf-8', + }); + + if (result.error || result.status !== 0) { + return { installed: false }; + } + + const version = extractVersion(result.stdout, result.stderr); + return version ? { installed: true, version } : { installed: true }; +} + +function findInSystemPath(toolName: string, runCommand: CommandRunner): string | null { + const lookupCommand = process.platform === 'win32' ? 'where' : 'which'; + const result = runCommand(lookupCommand, [toolName]); + + if (result.error || result.status !== 0) { + return null; + } + + const candidates = String(result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + for (const candidate of candidates) { + if (findExecutableAt(candidate)) { + return candidate; + } + } + + return null; +} + +function extractVersion(stdout?: string, stderr?: string): string | undefined { + const combined = `${stdout || ''}\n${stderr || ''}` + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + return combined || undefined; +} + +function findExecutableAt(targetPath: string): string | null { + return fileExists(targetPath) ? targetPath : null; +} + +function fileExists(targetPath: string): boolean { + try { + return fs.existsSync(targetPath); + } catch { + return false; + } +} diff --git a/src/services/static-analysis/tool-installation.ts b/src/services/static-analysis/tool-installation.ts new file mode 100644 index 0000000..0830be0 --- /dev/null +++ b/src/services/static-analysis/tool-installation.ts @@ -0,0 +1,831 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import * as childProcess from 'child_process'; +import { LoggerService } from '../../common/logger'; +import { ToolInstalledInfo, CommandRunner } from './tool-discovery'; +import { ToolPaths } from './tool-paths'; + +const STATIC_ANALYSIS_LOG_CATEGORY = 'static-analysis'; + +export interface InstallResult { + success: boolean; + executable: string | null; + error?: string; +} + +export interface ToolInstallationDependencies { + runCommand: CommandRunner; + isToolInstalled: (executablePath: string) => ToolInstalledInfo; + findExecutable: (toolName: string) => string | null; + allowSystemDependenciesInstall?: boolean; +} + +interface InstallCommand { + cmd: string; + args: string[]; +} + +interface NamedInstallCommand extends InstallCommand { + label: string; +} + +interface PrerequisiteInstallResult { + success: boolean; + error?: string; +} + +const UNIX_COMMAND_PATH_FALLBACKS: Record = { + brew: ['/opt/homebrew/bin/brew', '/usr/local/bin/brew', '/home/linuxbrew/.linuxbrew/bin/brew'], + 'apt-get': ['/usr/bin/apt-get', '/bin/apt-get'], + dnf: ['/usr/bin/dnf', '/bin/dnf'], + yum: ['/usr/bin/yum', '/bin/yum'], + microdnf: ['/usr/bin/microdnf', '/bin/microdnf'], + pacman: ['/usr/bin/pacman', '/bin/pacman'], + zypper: ['/usr/bin/zypper', '/bin/zypper'], + apk: ['/sbin/apk', '/usr/sbin/apk'], + pkg: ['/usr/sbin/pkg', '/sbin/pkg'], + port: ['/opt/local/bin/port'], + nix: ['/usr/bin/nix', '/bin/nix', '/nix/var/nix/profiles/default/bin/nix'], + sudo: ['/usr/bin/sudo', '/bin/sudo'], +}; + +export function installSemgrep(deps: ToolInstallationDependencies): InstallResult { + LoggerService.info('Semgrep not found. Attempting install...', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + + ensureDirectory(ToolPaths.getBinDir()); + + const venvPath = ToolPaths.getVenvDir(); + const isWindows = process.platform === 'win32'; + const pythonCandidates = isWindows ? ['py', 'python'] : ['python3', 'python']; + + let venvCreated = false; + let venvCreateError = ''; + + for (const pythonCmd of pythonCandidates) { + const createResult = deps.runCommand(pythonCmd, ['-m', 'venv', venvPath]); + if (createResult.status === 0 && !createResult.error) { + venvCreated = true; + break; + } + venvCreateError = formatCommandError( + pythonCmd, + ['-m', 'venv', venvPath], + createResult + ); + } + + if (venvCreated) { + const pipPath = path.join( + venvPath, + isWindows ? 'Scripts' : 'bin', + isWindows ? 'pip.exe' : 'pip' + ); + const semgrepPath = path.join( + venvPath, + isWindows ? 'Scripts' : 'bin', + isWindows ? 'semgrep.exe' : 'semgrep' + ); + + const installResult = deps.runCommand(pipPath, ['install', 'semgrep']); + if (installResult.status === 0 && !installResult.error && fileExists(semgrepPath)) { + const managedExecutable = ToolPaths.getManagedExecutablePath('semgrep'); + linkOrCopyExecutable(semgrepPath, managedExecutable); + + if (deps.isToolInstalled(managedExecutable).installed) { + LoggerService.info(`Semgrep installed at ${managedExecutable}`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { success: true, executable: managedExecutable }; + } + + if (deps.isToolInstalled(semgrepPath).installed) { + LoggerService.info(`Semgrep installed at ${semgrepPath}`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { success: true, executable: semgrepPath }; + } + } + + const installError = formatCommandError(pipPath, ['install', 'semgrep'], installResult); + LoggerService.warn(`Semgrep venv install failed: ${installError}`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + } else { + LoggerService.warn(`Semgrep venv creation failed: ${venvCreateError}`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + } + + // Fallback: attempt user-level pip install. + const fallback = installSemgrepWithUserPip(deps); + if (fallback.success) { + return fallback; + } + + return { + success: false, + executable: null, + error: fallback.error || venvCreateError || 'Failed to install Semgrep', + }; +} + +export function installQlty(deps: ToolInstallationDependencies): InstallResult { + LoggerService.info('Qlty not found. Attempting install...', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + + ensureDirectory(ToolPaths.getBinDir()); + + if (process.platform === 'win32') { + return installQltyOnWindows(deps); + } + + const installCommand = getQltyInstallCommand(deps); + let installResult = deps.runCommand(installCommand.cmd, installCommand.args); + let installError = formatCommandError(installCommand.cmd, installCommand.args, installResult); + if (installResult.status !== 0 || installResult.error) { + LoggerService.warn(`Qlty installer script failed on first attempt: ${installError}`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + } + + if ((installResult.status !== 0 || installResult.error) && isMissingXzError(installResult)) { + if (canAutoInstallSystemDeps(deps)) { + LoggerService.warn( + 'Qlty installer reported missing xz dependency. Attempting automatic xz installation.', + { category: STATIC_ANALYSIS_LOG_CATEGORY } + ); + const prerequisiteInstall = installXzPrerequisite(deps); + if (prerequisiteInstall.success) { + installResult = deps.runCommand(installCommand.cmd, installCommand.args); + installError = formatCommandError(installCommand.cmd, installCommand.args, installResult); + if (installResult.status !== 0 || installResult.error) { + LoggerService.warn(`Qlty installer still failed after xz install: ${installError}`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + } + } else { + installError = [installError, prerequisiteInstall.error].filter(Boolean).join(' | '); + LoggerService.warn( + `Automatic xz installation failed before qlty retry: ${prerequisiteInstall.error}`, + { + category: STATIC_ANALYSIS_LOG_CATEGORY, + } + ); + } + } else { + const manualInstallMessage = buildManualXzInstallMessage(); + installError = [installError, manualInstallMessage].filter(Boolean).join(' | '); + LoggerService.warn( + `Qlty installer requires xz but staticAnalysis.autoInstallSystemDeps is disabled. ${manualInstallMessage}`, + { + category: STATIC_ANALYSIS_LOG_CATEGORY, + } + ); + } + } + + if (installResult.status !== 0 || installResult.error) { + LoggerService.warn('Attempting qlty installation fallback via Homebrew.', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + const fallbackResult = installQltyWithHomebrew(deps); + if (fallbackResult.success && fallbackResult.executable) { + const resolved = finalizeInstalledQlty(deps, fallbackResult.executable); + if (resolved.success) { + return resolved; + } + installError = [installError, resolved.error].filter(Boolean).join(' | '); + } else if (fallbackResult.error) { + installError = [installError, fallbackResult.error].filter(Boolean).join(' | '); + LoggerService.warn(`Qlty Homebrew fallback failed: ${fallbackResult.error}`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + } + + return { success: false, executable: null, error: installError }; + } + + const discoveredExecutable = locateQltyExecutable(deps); + if (!discoveredExecutable) { + LoggerService.warn( + 'Qlty install finished but executable was not discovered in PATH or known install locations.', + { category: STATIC_ANALYSIS_LOG_CATEGORY } + ); + return { + success: false, + executable: null, + error: + 'Qlty installer completed, but executable was not found in PATH or known install locations.', + }; + } + + return finalizeInstalledQlty(deps, discoveredExecutable); +} + +function installQltyOnWindows(deps: ToolInstallationDependencies): InstallResult { + const attempts = getWindowsQltyInstallCommands(deps); + const attemptErrors: string[] = []; + + for (const attempt of attempts) { + LoggerService.info(`Attempting qlty install via ${attempt.label}.`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + + const outcome = runQltyInstallAttempt(deps, attempt); + if (outcome.success && outcome.executable) { + return finalizeInstalledQlty(deps, outcome.executable); + } + + if (outcome.error) { + attemptErrors.push(`${attempt.label}: ${outcome.error}`); + } + } + + return { + success: false, + executable: null, + error: attemptErrors.length + ? attemptErrors.join(' | ') + : 'Failed to install qlty on Windows.', + }; +} + +function runQltyInstallAttempt( + deps: ToolInstallationDependencies, + attempt: NamedInstallCommand +): InstallResult { + let installResult = deps.runCommand(attempt.cmd, attempt.args); + let installError = formatCommandError(attempt.cmd, attempt.args, installResult); + + if (installResult.status !== 0 || installResult.error) { + LoggerService.warn(`Qlty install attempt (${attempt.label}) failed: ${installError}`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + } + + if ((installResult.status !== 0 || installResult.error) && isMissingXzError(installResult)) { + if (canAutoInstallSystemDeps(deps)) { + LoggerService.warn( + `Qlty install attempt (${attempt.label}) requires xz. Attempting automatic xz installation.`, + { category: STATIC_ANALYSIS_LOG_CATEGORY } + ); + const prerequisiteInstall = installXzPrerequisite(deps); + if (prerequisiteInstall.success) { + installResult = deps.runCommand(attempt.cmd, attempt.args); + installError = formatCommandError(attempt.cmd, attempt.args, installResult); + if (installResult.status !== 0 || installResult.error) { + LoggerService.warn( + `Qlty install attempt (${attempt.label}) still failed after xz install: ${installError}`, + { category: STATIC_ANALYSIS_LOG_CATEGORY } + ); + } + } else { + installError = [installError, prerequisiteInstall.error].filter(Boolean).join(' | '); + LoggerService.warn( + `Automatic xz installation failed before qlty attempt (${attempt.label}) retry: ${prerequisiteInstall.error}`, + { category: STATIC_ANALYSIS_LOG_CATEGORY } + ); + } + } else { + const manualInstallMessage = buildManualXzInstallMessage(); + installError = [installError, manualInstallMessage].filter(Boolean).join(' | '); + LoggerService.warn( + `Qlty install attempt (${attempt.label}) requires xz but staticAnalysis.autoInstallSystemDeps is disabled. ${manualInstallMessage}`, + { category: STATIC_ANALYSIS_LOG_CATEGORY } + ); + } + } + + if (installResult.status !== 0 || installResult.error) { + return { success: false, executable: null, error: installError }; + } + + const discoveredExecutable = locateQltyExecutable(deps); + if (!discoveredExecutable) { + return { + success: false, + executable: null, + error: + 'qlty install command succeeded, but executable was not found in PATH or known install locations.', + }; + } + + return { success: true, executable: discoveredExecutable }; +} + +function getWindowsQltyInstallCommands( + deps: ToolInstallationDependencies +): NamedInstallCommand[] { + const attempts: NamedInstallCommand[] = []; + const winget = resolveCommandPath('winget', deps); + const choco = resolveCommandPath('choco', deps); + const scoop = resolveCommandPath('scoop', deps); + + if (winget) { + attempts.push({ + label: 'winget-id', + cmd: winget, + args: [ + 'install', + '--id', + 'Qlty.qlty', + '--exact', + '--silent', + '--accept-package-agreements', + '--accept-source-agreements', + ], + }); + attempts.push({ + label: 'winget', + cmd: winget, + args: [ + 'install', + 'qlty', + '--silent', + '--accept-package-agreements', + '--accept-source-agreements', + ], + }); + } + + if (choco) { + attempts.push({ + label: 'choco', + cmd: choco, + args: ['install', 'qlty', '-y'], + }); + } + + if (scoop) { + attempts.push({ + label: 'scoop', + cmd: scoop, + args: ['install', 'qlty'], + }); + } + + attempts.push({ + label: 'powershell-script', + ...getQltyInstallCommand(deps), + }); + + return attempts; +} + +function getQltyInstallCommand(deps: ToolInstallationDependencies): InstallCommand { + if (process.platform === 'win32') { + return { + cmd: resolveCommandPath('powershell', deps) || 'powershell', + args: [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + 'iwr -useb https://qlty.sh | iex', + ], + }; + } + + return { + cmd: resolveCommandPath('sh', deps) || 'sh', + args: ['-c', 'curl -fsSL https://qlty.sh | sh'], + }; +} + +function finalizeInstalledQlty( + deps: ToolInstallationDependencies, + discoveredExecutable: string +): InstallResult { + const managedExecutable = ToolPaths.getManagedExecutablePath('qlty'); + linkOrCopyExecutable(discoveredExecutable, managedExecutable); + if (deps.isToolInstalled(managedExecutable).installed) { + LoggerService.info(`Qlty installed at ${managedExecutable}`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { success: true, executable: managedExecutable }; + } + + LoggerService.info(`Qlty available at ${discoveredExecutable}`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { success: true, executable: discoveredExecutable }; +} + +function installXzPrerequisite( + deps: ToolInstallationDependencies +): PrerequisiteInstallResult { + const candidateCommands = getXzInstallCommands(deps); + if (candidateCommands.length === 0) { + return { + success: false, + error: + `qlty install requires xz, and no supported package manager (winget, choco, scoop, brew, apt-get, dnf, yum, microdnf, pacman, zypper, apk, pkg, port, nix) was found in PATH or common locations. ${buildManualXzInstallMessage()}`, + }; + } + + const attemptErrors: string[] = []; + for (const command of candidateCommands) { + LoggerService.info( + `Attempting to install xz prerequisite via: ${command.cmd} ${command.args.join(' ')}`, + { + category: STATIC_ANALYSIS_LOG_CATEGORY, + } + ); + const result = deps.runCommand(command.cmd, command.args); + if (result.status === 0 && !result.error) { + LoggerService.info('Installed xz prerequisite for qlty.', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { success: true }; + } + const commandError = formatCommandError(command.cmd, command.args, result); + attemptErrors.push(commandError); + LoggerService.warn(`Failed xz install attempt: ${commandError}`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + } + + return { + success: false, + error: `qlty install requires xz and automatic install failed: ${attemptErrors.join(' | ')} | ${buildManualXzInstallMessage()}`, + }; +} + +function getXzInstallCommands(deps: ToolInstallationDependencies): InstallCommand[] { + const commands: InstallCommand[] = []; + const winget = resolveCommandPath('winget', deps); + const choco = resolveCommandPath('choco', deps); + const scoop = resolveCommandPath('scoop', deps); + const brew = resolveCommandPath('brew', deps); + const aptGet = resolveCommandPath('apt-get', deps); + const dnf = resolveCommandPath('dnf', deps); + const yum = resolveCommandPath('yum', deps); + const microdnf = resolveCommandPath('microdnf', deps); + const pacman = resolveCommandPath('pacman', deps); + const zypper = resolveCommandPath('zypper', deps); + const apk = resolveCommandPath('apk', deps); + const pkg = resolveCommandPath('pkg', deps); + const port = resolveCommandPath('port', deps); + const nix = resolveCommandPath('nix', deps); + const sudo = resolveCommandPath('sudo', deps); + + if (winget) { + commands.push({ + cmd: winget, + args: [ + 'install', + '--id', + 'TukaaniProject.XZ', + '--exact', + '--silent', + '--accept-package-agreements', + '--accept-source-agreements', + ], + }); + commands.push({ + cmd: winget, + args: [ + 'install', + 'xz', + '--silent', + '--accept-package-agreements', + '--accept-source-agreements', + ], + }); + } + if (choco) { + commands.push({ cmd: choco, args: ['install', 'xz', '-y'] }); + } + if (scoop) { + commands.push({ cmd: scoop, args: ['install', 'xz'] }); + } + if (brew) { + commands.push({ cmd: brew, args: ['install', 'xz'] }); + } + if (aptGet) { + commands.push({ cmd: aptGet, args: ['install', '-y', 'xz-utils'] }); + if (sudo) { + commands.push({ cmd: sudo, args: ['-n', aptGet, 'install', '-y', 'xz-utils'] }); + } + } + if (dnf) { + commands.push({ cmd: dnf, args: ['install', '-y', 'xz'] }); + if (sudo) { + commands.push({ cmd: sudo, args: ['-n', dnf, 'install', '-y', 'xz'] }); + } + } + if (yum) { + commands.push({ cmd: yum, args: ['install', '-y', 'xz'] }); + if (sudo) { + commands.push({ cmd: sudo, args: ['-n', yum, 'install', '-y', 'xz'] }); + } + } + if (microdnf) { + commands.push({ cmd: microdnf, args: ['install', '-y', 'xz'] }); + if (sudo) { + commands.push({ cmd: sudo, args: ['-n', microdnf, 'install', '-y', 'xz'] }); + } + } + if (pacman) { + commands.push({ cmd: pacman, args: ['-S', '--noconfirm', 'xz'] }); + if (sudo) { + commands.push({ cmd: sudo, args: ['-n', pacman, '-S', '--noconfirm', 'xz'] }); + } + } + if (zypper) { + commands.push({ cmd: zypper, args: ['--non-interactive', 'install', 'xz'] }); + if (sudo) { + commands.push({ + cmd: sudo, + args: ['-n', zypper, '--non-interactive', 'install', 'xz'], + }); + } + } + if (apk) { + commands.push({ cmd: apk, args: ['add', 'xz'] }); + if (sudo) { + commands.push({ cmd: sudo, args: ['-n', apk, 'add', 'xz'] }); + } + } + if (pkg) { + commands.push({ cmd: pkg, args: ['install', '-y', 'xz'] }); + if (sudo) { + commands.push({ cmd: sudo, args: ['-n', pkg, 'install', '-y', 'xz'] }); + } + } + if (port) { + commands.push({ cmd: port, args: ['install', 'xz'] }); + if (sudo) { + commands.push({ cmd: sudo, args: ['-n', port, 'install', 'xz'] }); + } + } + if (nix) { + commands.push({ cmd: nix, args: ['profile', 'install', 'nixpkgs#xz'] }); + } + + return commands; +} + +function installQltyWithHomebrew(deps: ToolInstallationDependencies): InstallResult { + const brew = resolveCommandPath('brew', deps); + if (!brew) { + return { + success: false, + executable: null, + error: 'qlty fallback install skipped: brew not found in PATH or common locations.', + }; + } + + LoggerService.info(`Running qlty fallback install with Homebrew at ${brew}`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + const installResult = deps.runCommand(brew, ['install', 'qlty']); + if (installResult.status !== 0 || installResult.error) { + return { + success: false, + executable: null, + error: formatCommandError(brew, ['install', 'qlty'], installResult), + }; + } + + const discoveredExecutable = locateQltyExecutable(deps); + if (!discoveredExecutable) { + return { + success: false, + executable: null, + error: 'brew installed qlty, but executable was not found in PATH or known install locations.', + }; + } + + return { success: true, executable: discoveredExecutable }; +} + +function installSemgrepWithUserPip(deps: ToolInstallationDependencies): InstallResult { + const fallbackCommands = + process.platform === 'win32' + ? [ + { cmd: 'py', args: ['-m', 'pip', 'install', '--user', 'semgrep'] }, + { cmd: 'pip', args: ['install', '--user', 'semgrep'] }, + ] + : [ + { cmd: 'pip3', args: ['install', '--user', 'semgrep'] }, + { cmd: 'pip', args: ['install', '--user', 'semgrep'] }, + ]; + + let lastError = ''; + + for (const fallback of fallbackCommands) { + const result = deps.runCommand(fallback.cmd, fallback.args); + if (result.status === 0 && !result.error) { + const detected = deps.findExecutable('semgrep'); + if (detected) { + LoggerService.info(`Semgrep installed via fallback at ${detected}`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { success: true, executable: detected }; + } + } + lastError = formatCommandError(fallback.cmd, fallback.args, result); + } + + return { + success: false, + executable: null, + error: lastError || 'Fallback semgrep install failed', + }; +} + +function formatCommandError( + command: string, + args: string[], + result: childProcess.SpawnSyncReturns +): string { + const stderr = result.stderr ? result.stderr.trim() : ''; + const stdout = result.stdout ? result.stdout.trim() : ''; + if (result.error) { + return `${command} ${args.join(' ')} failed: ${result.error.message}`; + } + if (stderr) { + return `${command} ${args.join(' ')} failed: ${stderr}`; + } + if (stdout) { + return `${command} ${args.join(' ')} failed: ${stdout}`; + } + return `${command} ${args.join(' ')} failed with exit code ${result.status ?? 'unknown'}`; +} + +function isMissingXzError(result: childProcess.SpawnSyncReturns): boolean { + const combined = `${result.stderr || ''}\n${result.stdout || ''}`.toLowerCase(); + return ( + (combined.includes('xz') && combined.includes('required')) || + combined.includes('install qlty') && combined.includes('xz') + ); +} + +function locateQltyExecutable(deps: ToolInstallationDependencies): string | null { + const fromPath = deps.findExecutable('qlty'); + if (fromPath) { + return fromPath; + } + + const candidates = + process.platform === 'win32' + ? getWindowsQltyExecutableCandidates() + : [ + path.join(os.homedir(), '.qlty', 'bin', ToolPaths.binaryName('qlty')), + path.join(os.homedir(), '.local', 'bin', ToolPaths.binaryName('qlty')), + ]; + + for (const candidate of candidates) { + const executable = findExecutableAt(candidate); + if (executable) { + appendDirectoryToProcessPath(path.dirname(executable)); + return executable; + } + } + + return null; +} + +function resolveCommandPath( + commandName: string, + deps: ToolInstallationDependencies +): string | null { + const discovered = deps.findExecutable(commandName); + if (discovered) { + return discovered; + } + + const fallbacks = getCommandPathFallbacks(commandName); + if (!fallbacks) { + return null; + } + + for (const candidate of fallbacks) { + if (findExecutableAt(candidate)) { + return candidate; + } + } + + return null; +} + +function getCommandPathFallbacks(commandName: string): string[] | null { + if (process.platform !== 'win32') { + return UNIX_COMMAND_PATH_FALLBACKS[commandName] || null; + } + + const userProfile = process.env.USERPROFILE || os.homedir(); + const localAppData = process.env.LOCALAPPDATA || path.join(userProfile, 'AppData', 'Local'); + const programData = process.env.ProgramData || 'C:\\ProgramData'; + const windowsFallbacks: Record = { + winget: [ + path.join(localAppData, 'Microsoft', 'WindowsApps', 'winget.exe'), + 'C:\\Windows\\System32\\winget.exe', + ], + choco: [path.join(programData, 'chocolatey', 'bin', 'choco.exe')], + scoop: [ + path.join(userProfile, 'scoop', 'shims', 'scoop.cmd'), + path.join(userProfile, 'scoop', 'shims', 'scoop.exe'), + ], + powershell: [ + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + ], + sh: ['C:\\Program Files\\Git\\bin\\sh.exe'], + sudo: [], + }; + + return windowsFallbacks[commandName] || null; +} + +function getWindowsQltyExecutableCandidates(): string[] { + const userProfile = process.env.USERPROFILE || os.homedir(); + const localAppData = process.env.LOCALAPPDATA || path.join(userProfile, 'AppData', 'Local'); + const programData = process.env.ProgramData || 'C:\\ProgramData'; + + return [ + path.join(userProfile, '.qlty', 'bin', 'qlty.exe'), + path.join(userProfile, '.local', 'bin', 'qlty.exe'), + path.join(userProfile, 'scoop', 'shims', 'qlty.exe'), + path.join(userProfile, 'scoop', 'apps', 'qlty', 'current', 'qlty.exe'), + path.join(programData, 'chocolatey', 'bin', 'qlty.exe'), + path.join(localAppData, 'Microsoft', 'WinGet', 'Links', 'qlty.exe'), + path.join(localAppData, 'Programs', 'qlty', 'qlty.exe'), + path.join(localAppData, 'qlty', 'bin', 'qlty.exe'), + ]; +} + +function appendDirectoryToProcessPath(directoryPath: string): void { + if (!directoryPath) { + return; + } + + const currentPath = process.env.PATH || ''; + const delimiter = path.delimiter; + const segments = currentPath + .split(delimiter) + .map((segment) => segment.trim()) + .filter(Boolean); + + const alreadyPresent = segments.some((segment) => + process.platform === 'win32' + ? segment.toLowerCase() === directoryPath.toLowerCase() + : segment === directoryPath + ); + + if (alreadyPresent) { + return; + } + + process.env.PATH = currentPath ? `${directoryPath}${delimiter}${currentPath}` : directoryPath; + LoggerService.info(`Added ${directoryPath} to PATH for current process.`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); +} + +function canAutoInstallSystemDeps(deps: ToolInstallationDependencies): boolean { + return deps.allowSystemDependenciesInstall === true; +} + +function buildManualXzInstallMessage(): string { + return ( + 'Install xz manually and retry (macOS: brew install xz; Ubuntu/Debian: sudo apt-get install -y xz-utils; ' + + 'Fedora/RHEL: sudo dnf install -y xz; Alpine: sudo apk add xz; Arch: sudo pacman -S --noconfirm xz; ' + + 'Windows: winget install TukaaniProject.XZ) or set staticAnalysis.autoInstallSystemDeps=true.' + ); +} + +function ensureDirectory(targetDir: string): void { + fs.mkdirSync(targetDir, { recursive: true }); +} + +function fileExists(targetPath: string): boolean { + try { + return fs.existsSync(targetPath); + } catch { + return false; + } +} + +function findExecutableAt(targetPath: string): string | null { + return fileExists(targetPath) ? targetPath : null; +} + +function linkOrCopyExecutable(sourcePath: string, targetPath: string): void { + ensureDirectory(path.dirname(targetPath)); + if (fileExists(targetPath)) { + fs.rmSync(targetPath, { force: true }); + } + + try { + fs.symlinkSync(sourcePath, targetPath); + } catch { + fs.copyFileSync(sourcePath, targetPath); + fs.chmodSync(targetPath, 0o755); + } +} diff --git a/src/services/static-analysis/tool-installer-cache.ts b/src/services/static-analysis/tool-installer-cache.ts new file mode 100644 index 0000000..4f878dd --- /dev/null +++ b/src/services/static-analysis/tool-installer-cache.ts @@ -0,0 +1,60 @@ +import { ToolAvailability, ToolStatus } from './types'; + +type AvailabilityComputer = () => ToolAvailability | Promise; + +export class ToolInstallerCache { + private static cachedSemgrepByMode: Map = new Map(); + private static cachedQltyByMode: Map = new Map(); + private static inFlightByMode: Map> = new Map(); + + static reset(): void { + ToolInstallerCache.cachedSemgrepByMode.clear(); + ToolInstallerCache.cachedQltyByMode.clear(); + ToolInstallerCache.inFlightByMode.clear(); + } + + static async getOrCompute( + autoInstall: boolean, + autoInstallSystemDeps: boolean, + computeAvailability: AvailabilityComputer + ): Promise { + const cacheKey = ToolInstallerCache.getCacheKey(autoInstall, autoInstallSystemDeps); + + // Both tools cached β€” return immediately without any computation + const cachedSemgrep = ToolInstallerCache.cachedSemgrepByMode.get(cacheKey); + const cachedQlty = ToolInstallerCache.cachedQltyByMode.get(cacheKey); + if (cachedSemgrep && cachedQlty) { + return { semgrep: cachedSemgrep, qlty: cachedQlty }; + } + + // Deduplicate concurrent calls for the same mode + const inFlight = ToolInstallerCache.inFlightByMode.get(cacheKey); + if (inFlight) { + return inFlight; + } + + const availabilityPromise = Promise.resolve(computeAvailability()) + .then((availability) => { + // Cache each tool independently β€” only cache successful results so that + // transient failures (network down, install timeout) are retried on the + // next call rather than being locked out for the process lifetime. + if (availability.semgrep.available) { + ToolInstallerCache.cachedSemgrepByMode.set(cacheKey, availability.semgrep); + } + if (availability.qlty.available) { + ToolInstallerCache.cachedQltyByMode.set(cacheKey, availability.qlty); + } + return availability; + }) + .finally(() => { + ToolInstallerCache.inFlightByMode.delete(cacheKey); + }); + + ToolInstallerCache.inFlightByMode.set(cacheKey, availabilityPromise); + return availabilityPromise; + } + + private static getCacheKey(autoInstall: boolean, autoInstallSystemDeps: boolean): string { + return `auto:${autoInstall ? 'on' : 'off'}|sysdeps:${autoInstallSystemDeps ? 'on' : 'off'}`; + } +} diff --git a/src/services/static-analysis/tool-installer.ts b/src/services/static-analysis/tool-installer.ts new file mode 100644 index 0000000..6d3754f --- /dev/null +++ b/src/services/static-analysis/tool-installer.ts @@ -0,0 +1,162 @@ +import * as childProcess from 'child_process'; +import { LoggerService } from '../../common/logger'; +import { ToolAvailability, ToolStatus } from './types'; +import { + findExecutable as resolveExecutable, + isToolInstalled as checkToolInstalled, + CommandRunner, +} from './tool-discovery'; +import { ToolInstallerCache } from './tool-installer-cache'; +import { ToolPaths } from './tool-paths'; +import { + installSemgrep as installSemgrepWorkflow, + installQlty as installQltyWorkflow, + InstallResult, +} from './tool-installation'; + +const STATIC_ANALYSIS_LOG_CATEGORY = 'static-analysis'; + +export class ToolInstaller { + private static readonly defaultCommandRunner: CommandRunner = (command, args) => { + return childProcess.spawnSync(command, args, { + stdio: 'pipe', + encoding: 'utf-8', + }); + }; + + private static commandRunner: CommandRunner = ToolInstaller.defaultCommandRunner; + /** + * Test helper to clear process-level cache between tests. + */ + static resetForTests(): void { + ToolInstallerCache.reset(); + ToolInstaller.commandRunner = ToolInstaller.defaultCommandRunner; + } + + /** + * Test helper to replace command execution behavior. + */ + static setCommandRunnerForTests(runner: CommandRunner | null): void { + ToolInstaller.commandRunner = runner ?? ToolInstaller.defaultCommandRunner; + } + + static findExecutable(toolName: string): string | null { + return resolveExecutable(toolName, { + runCommand: (command, args) => ToolInstaller.runCommand(command, args), + isToolInstalled: (executablePath) => ToolInstaller.isToolInstalled(executablePath), + getManagedExecutablePath: (name) => ToolPaths.getManagedExecutablePath(name), + }); + } + + static isToolInstalled(executablePath: string): { installed: boolean; version?: string } { + return checkToolInstalled(executablePath); + } + + static installSemgrep(): InstallResult { + return installSemgrepWorkflow({ + runCommand: (command, args) => ToolInstaller.runCommand(command, args), + isToolInstalled: (executablePath) => ToolInstaller.isToolInstalled(executablePath), + findExecutable: (toolName) => ToolInstaller.findExecutable(toolName), + }); + } + + static installQlty(allowSystemDependenciesInstall = false): InstallResult { + return installQltyWorkflow({ + runCommand: (command, args) => ToolInstaller.runCommand(command, args), + isToolInstalled: (executablePath) => ToolInstaller.isToolInstalled(executablePath), + findExecutable: (toolName) => ToolInstaller.findExecutable(toolName), + allowSystemDependenciesInstall, + }); + } + + static async ensureAvailable( + autoInstall: boolean, + autoInstallSystemDeps = false + ): Promise { + return ToolInstallerCache.getOrCompute(autoInstall, autoInstallSystemDeps, () => + ToolInstaller.computeAvailability(autoInstall, autoInstallSystemDeps) + ); + } + + private static computeAvailability( + autoInstall: boolean, + autoInstallSystemDeps: boolean + ): ToolAvailability { + return { + semgrep: ToolInstaller.resolveTool('semgrep', autoInstall, autoInstallSystemDeps), + qlty: ToolInstaller.resolveTool('qlty', autoInstall, autoInstallSystemDeps), + }; + } + + private static resolveTool( + toolName: 'semgrep' | 'qlty', + autoInstall: boolean, + autoInstallSystemDeps: boolean + ): ToolStatus { + const existing = ToolInstaller.findExecutable(toolName); + if (existing) { + LoggerService.info(`${toolName} detected at ${existing}; auto-install skipped.`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { + available: true, + executable: existing, + installedNow: false, + }; + } + + if (!autoInstall) { + LoggerService.warn(`${toolName} is missing and auto-install is disabled.`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { + available: false, + executable: null, + installedNow: false, + error: `${toolName} is not available and auto-install is disabled.`, + }; + } + + LoggerService.info(`${toolName} not found. Starting auto-install flow.`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + const installResult = + toolName === 'semgrep' + ? ToolInstaller.installSemgrep() + : ToolInstaller.installQlty(autoInstallSystemDeps); + + if (installResult.success && installResult.executable) { + LoggerService.info( + `${toolName} auto-install succeeded at ${installResult.executable}.`, + { + category: STATIC_ANALYSIS_LOG_CATEGORY, + } + ); + return { + available: true, + executable: installResult.executable, + installedNow: true, + }; + } + + LoggerService.warn( + `${toolName} auto-install failed: ${installResult.error || `Failed to install ${toolName}`}`, + { + category: STATIC_ANALYSIS_LOG_CATEGORY, + } + ); + return { + available: false, + executable: null, + installedNow: false, + error: installResult.error || `Failed to install ${toolName}`, + }; + } + + private static runCommand( + command: string, + args: string[] + ): childProcess.SpawnSyncReturns { + return ToolInstaller.commandRunner(command, args); + } +} diff --git a/src/services/static-analysis/tool-paths.ts b/src/services/static-analysis/tool-paths.ts new file mode 100644 index 0000000..959b7ab --- /dev/null +++ b/src/services/static-analysis/tool-paths.ts @@ -0,0 +1,28 @@ +import os from 'os'; +import path from 'path'; + +export class ToolPaths { + static getToolsDir(): string { + const override = process.env.CODEWAVE_TOOLS_DIR; + if (override && override.trim().length > 0) { + return path.resolve(override); + } + return path.join(os.homedir(), '.codewave', 'tools'); + } + + static getBinDir(): string { + return path.join(ToolPaths.getToolsDir(), 'bin'); + } + + static getVenvDir(): string { + return path.join(ToolPaths.getToolsDir(), 'semgrep-venv'); + } + + static getManagedExecutablePath(toolName: string): string { + return path.join(ToolPaths.getBinDir(), ToolPaths.binaryName(toolName)); + } + + static binaryName(toolName: string): string { + return process.platform === 'win32' ? `${toolName}.exe` : toolName; + } +} diff --git a/src/services/static-analysis/tool-runner-registry.ts b/src/services/static-analysis/tool-runner-registry.ts new file mode 100644 index 0000000..ac7dc0f --- /dev/null +++ b/src/services/static-analysis/tool-runner-registry.ts @@ -0,0 +1,97 @@ +import { QltyRunner } from './qlty-runner'; +import { QltySmellsRunner } from './qlty-smells-runner'; +import { SemgrepRunner } from './semgrep-runner'; +import { + FindingSource, + IToolRunner, + StaticAnalysisConfig, + ToolAvailability, +} from './types'; + +export type ToolRunnerPlanId = 'semgrep' | 'qlty-check' | 'qlty-smells'; + +export interface ToolRunnerPlan { + id: ToolRunnerPlanId; + source: FindingSource; + runnable: boolean; + timeoutMs: number; + warnings: string[]; + createRunner: (() => IToolRunner) | null; +} + +export function buildToolRunnerPlans( + config: StaticAnalysisConfig, + availability: ToolAvailability +): ToolRunnerPlan[] { + const semgrepPlan = buildSemgrepPlan(config, availability); + const qltyCheckPlan = buildQltyCheckPlan(config, availability); + const qltySmellsPlan = buildQltySmellsPlan(config, availability); + return [semgrepPlan, qltyCheckPlan, qltySmellsPlan]; +} + +function buildSemgrepPlan( + config: StaticAnalysisConfig, + availability: ToolAvailability +): ToolRunnerPlan { + const executable = availability.semgrep.executable; + const runnable = Boolean(config.semgrep.enabled && availability.semgrep.available && executable); + const warnings: string[] = []; + + if (config.semgrep.enabled && !availability.semgrep.available && availability.semgrep.error) { + warnings.push(availability.semgrep.error); + } + + return { + id: 'semgrep', + source: 'semgrep', + runnable, + timeoutMs: config.semgrep.timeout, + warnings, + createRunner: + runnable && executable ? () => new SemgrepRunner(executable, config.semgrep.rules) : null, + }; +} + +function buildQltyCheckPlan( + config: StaticAnalysisConfig, + availability: ToolAvailability +): ToolRunnerPlan { + const executable = availability.qlty.executable; + const runnable = Boolean(config.qlty.enabled && availability.qlty.available && executable); + const warnings: string[] = []; + + if (config.qlty.enabled && !availability.qlty.available && availability.qlty.error) { + warnings.push(availability.qlty.error); + } + + return { + id: 'qlty-check', + source: 'qlty', + runnable, + timeoutMs: config.qlty.timeout, + warnings, + createRunner: runnable && executable ? () => new QltyRunner(executable) : null, + }; +} + +function buildQltySmellsPlan( + config: StaticAnalysisConfig, + availability: ToolAvailability +): ToolRunnerPlan { + const executable = availability.qlty.executable; + const runnable = Boolean( + config.qlty.enabled && config.qlty.smells.enabled && availability.qlty.available && executable + ); + + return { + id: 'qlty-smells', + source: 'qlty', + runnable, + timeoutMs: config.qlty.smells.timeout, + warnings: [], + createRunner: + runnable && executable + ? () => new QltySmellsRunner(executable, { includeDuplication: config.qlty.smells.includeDuplication }) + : null, + }; +} diff --git a/src/services/static-analysis/types.ts b/src/services/static-analysis/types.ts new file mode 100644 index 0000000..a53649f --- /dev/null +++ b/src/services/static-analysis/types.ts @@ -0,0 +1,180 @@ +// --- Enums / Unions ------------------------------------------------- + +export type FindingSeverity = 'error' | 'warning' | 'info'; +export type FindingSource = 'semgrep' | 'qlty'; +export type FindingRunnerId = 'semgrep' | 'qlty-check' | 'qlty-smells'; +export type FindingCategory = + | 'security' + | 'quality' + | 'complexity' + | 'duplication' + | 'style' + | 'bug'; + +// --- Severity ranking utility ---------------------------------------- + +export const SEVERITY_RANK: Record = { + error: 3, + warning: 2, + info: 1, +}; + +// --- Core Finding ---------------------------------------------------- + +export interface StaticAnalysisFinding { + source: FindingSource; + runnerId: FindingRunnerId; + file: string; + line: number; + column: number; + message: string; + severity: FindingSeverity; + category: FindingCategory; + ruleId: string; + /** Semgrep-specific metadata */ + cwe?: string[]; + owasp?: string[]; + /** Qlty-specific metadata */ + fixable?: boolean; +} + +// --- File Scope Resolution ------------------------------------------- + +export interface FileScopeResult { + /** Validated, deduplicated file paths */ + scanFiles: string[]; + /** Files in diff but not on disk */ + skippedMissing: string[]; + /** Files skipped by exclude globs */ + skippedExcluded: string[]; + /** Files filtered by extension (future) */ + skippedUnsupported: string[]; +} + +// --- Tool Availability ----------------------------------------------- + +export interface ToolStatus { + available: boolean; + /** Full path to binary (managed or system) */ + executable: string | null; + /** True if we just installed it this run */ + installedNow: boolean; + error?: string; +} + +export interface ToolAvailability { + semgrep: ToolStatus; + qlty: ToolStatus; +} + +// --- Summary (returned by service) ----------------------------------- + +export interface StaticAnalysisSummary { + available: { + semgrep: boolean; + qlty: boolean; + }; + timing: { + semgrepMs: number | null; + qltyMs: number | null; + toolAvailabilityMs?: number; + scopeResolutionMs?: number; + postProcessMs?: number; + overheadMs?: number; + totalMs: number; + }; + counts: { + total: number; + errors: number; + warnings: number; + info: number; + byCategory: Record; + byFile: Record; + byRunner: Record; + }; + findings: StaticAnalysisFinding[]; + truncated: boolean; + totalBeforeCap: number; + skippedFiles: { + missing: string[]; + excluded: string[]; + unsupported: string[]; + }; + warnings: string[]; +} + +// --- Raw Output Payload (for persistence) ---------------------------- + +export interface RawToolOutput { + semgrep?: { + stdout: string; + stderr: string; + exitCode: number | null; + timedOut: boolean; + }; + qlty?: { + stdout: string; + stderr: string; + exitCode: number | null; + timedOut: boolean; + }; + qltySmells?: { + stdout: string; + stderr: string; + exitCode: number | null; + timedOut: boolean; + }; +} + +// --- Config ---------------------------------------------------------- + +export interface StaticAnalysisConfig { + enabled: boolean; + /** Allow auto-installing missing tools */ + autoInstall: boolean; + /** Allow auto-installing missing system dependencies (e.g., xz for qlty) */ + autoInstallSystemDeps: boolean; + /** Glob patterns to exclude from scanning */ + excludedPaths: string[]; + semgrep: { + enabled: boolean; + /** e.g. ['auto', 'p/security-audit'] */ + rules: string[]; + /** Milliseconds */ + timeout: number; + }; + qlty: { + enabled: boolean; + /** Milliseconds */ + timeout: number; + smells: { + enabled: boolean; + /** Milliseconds */ + timeout: number; + includeDuplication: boolean; + }; + }; + /** Cap total findings (safety against token bloat) */ + maxFindings: number; + /** Filter out below this level */ + minSeverity: FindingSeverity; +} + +// --- Runner Interface (SOLID: Dependency Inversion) ------------------ + +export interface IToolRunner { + readonly name: FindingSource; + scan( + files: string[], + repoPath: string, + timeout: number + ): Promise<{ + findings: StaticAnalysisFinding[]; + rawOutput: { + stdout: string; + stderr: string; + exitCode: number | null; + timedOut: boolean; + }; + }>; +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..03f2f6e --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts', 'cli/**/*.test.ts'], + exclude: ['node_modules', 'dist'], + coverage: { + provider: 'v8', + reporter: ['text', 'html'], + include: ['src/mcp-server/**/*.ts'], + exclude: ['**/*.test.ts', '**/fixtures/**'], + }, + // Timeout for async tests + testTimeout: 30000, + // Clear mocks between tests + clearMocks: true, + restoreMocks: true, + }, +});