From f05b57f6eba8f5cb131cfd565691a3927354c534 Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Fri, 30 Jan 2026 13:53:43 +0100 Subject: [PATCH 01/24] feat: add MCP server implementation Includes: - MCP server core implementation - Documentation updates - Configuration changes --- .gitignore | 9 + README.md | 146 ++ cli/mcp-server.ts | 17 + docs/CONFIGURATION.md | 127 + docs/INDEX.md | 48 +- docs/MCP_EXAMPLES.md | 840 +++++++ docs/MCP_SETUP.md | 546 ++++ docs/MCP_TOOLS_REFERENCE.md | 1360 ++++++++++ docs/QUICK_REFERENCE.md | 103 + package-lock.json | 2635 +++++++++++++++++++- package.json | 14 +- src/mcp-server/__tests__/index.test.ts | 356 +++ src/mcp-server/index.ts | 135 + src/mcp-server/tools/handlers.ts | 3184 ++++++++++++++++++++++++ src/mcp-server/tools/tool-registry.ts | 349 +++ src/mcp-server/types.ts | 52 + vitest.config.ts | 21 + 17 files changed, 9905 insertions(+), 37 deletions(-) create mode 100644 cli/mcp-server.ts create mode 100644 docs/MCP_EXAMPLES.md create mode 100644 docs/MCP_SETUP.md create mode 100644 docs/MCP_TOOLS_REFERENCE.md create mode 100644 src/mcp-server/__tests__/index.test.ts create mode 100644 src/mcp-server/index.ts create mode 100644 src/mcp-server/tools/handlers.ts create mode 100644 src/mcp-server/tools/tool-registry.ts create mode 100644 src/mcp-server/types.ts create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore index bce9863..e641279 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 diff --git a/README.md b/README.md index 18c5729..59f5d7e 100644 --- a/README.md +++ b/README.md @@ -1062,6 +1062,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/mcp-server.ts b/cli/mcp-server.ts new file mode 100644 index 0000000..601b327 --- /dev/null +++ b/cli/mcp-server.ts @@ -0,0 +1,17 @@ +#!/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'; + +// Start the MCP server +startServer().catch((error) => { + console.error('Failed to start CodeWave MCP server:', error); + process.exit(1); +}); diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f6cf848..0cff1d1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -777,6 +777,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..9771034 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -104,6 +104,32 @@ Complete guide to all CodeWave documentation. - 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 - **[API.md](./API.md)** (716 lines) @@ -142,7 +168,10 @@ Complete guide to all CodeWave documentation. | 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 @@ -187,6 +216,9 @@ Complete guide to all CodeWave documentation. **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 @@ -287,6 +319,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 +364,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..2e78372 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", @@ -30,19 +31,22 @@ "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 +73,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 +118,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 +686,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 +781,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 +871,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 +1072,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 +1184,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 +1561,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 +1611,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 +1690,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 +1850,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 +2004,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 +2094,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 +2193,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 +2272,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 +2343,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 +2365,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 +2403,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 +2559,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 +2630,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 +2680,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 +2741,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 +2780,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 +2814,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 +2882,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 +2938,6 @@ "integrity": "sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -1670,6 +3084,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 +3104,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 +3128,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 +3375,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 +3470,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 +3495,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 +3770,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 +3933,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 +4015,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 +4037,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 +4096,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 +4142,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 +4292,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 +4339,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 +4460,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 +4486,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 +4541,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 +4582,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 +4811,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 +4844,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,6 +4869,20 @@ "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", @@ -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..9ac6176 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", 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..d28a2c2 --- /dev/null +++ b/src/mcp-server/tools/handlers.ts @@ -0,0 +1,3184 @@ +/** + * 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 { createAgentRegistry, getEvaluationRoot, saveEvaluationReports, createEvaluationDirectory } from '../../../cli/utils/shared.utils'; +import { CommitEvaluationOrchestrator } from '../../orchestrator/commit-evaluation-orchestrator'; +import { getCommitDiff, extractCommitHash, 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.', + }); +} + +// ============================================================================ +// 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 }, + }; +} + +// ============================================================================ +// 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}`); + + const loadedConfig = loadConfig(configFilePath); + 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; + 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, + 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()}`, + }) + ); + } 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 || [], + })), + }; + + // ======================================================================== + // 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, + }); + + 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) { + 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}`); + + const loadedConfig = loadConfig(configFilePath); + 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/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, + }, +}); From 5c9b9dcc00c517e64ed06bc59dc5d017df27f8ea Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Wed, 11 Feb 2026 14:57:09 +0100 Subject: [PATCH 02/24] feat: add static-analysis foundations and fix filesChanged propagation --- cli/commands/evaluate-command.ts | 4 +- cli/mcp-server.ts | 5 + .../logger/__tests__/logger.service.test.ts | 239 ++++++++++++++++++ src/common/logger/index.ts | 6 + src/common/logger/logger.service.ts | 71 ++++++ .../logger/transports/capture.transport.ts | 25 ++ src/common/logger/transports/cli.transport.ts | 27 ++ .../logger/transports/silent.transport.ts | 11 + src/common/logger/types.ts | 24 ++ src/config/config-loader.ts | 37 ++- src/config/config.interface.ts | 2 + src/config/default-config.ts | 30 +++ src/mcp-server/tools/handlers.ts | 5 +- .../__tests__/spawn-helper.test.ts | 63 +++++ src/services/static-analysis/spawn-helper.ts | 95 +++++++ src/services/static-analysis/types.ts | 159 ++++++++++++ 16 files changed, 799 insertions(+), 4 deletions(-) create mode 100644 src/common/logger/__tests__/logger.service.test.ts create mode 100644 src/common/logger/index.ts create mode 100644 src/common/logger/logger.service.ts create mode 100644 src/common/logger/transports/capture.transport.ts create mode 100644 src/common/logger/transports/cli.transport.ts create mode 100644 src/common/logger/transports/silent.transport.ts create mode 100644 src/common/logger/types.ts create mode 100644 src/services/static-analysis/__tests__/spawn-helper.test.ts create mode 100644 src/services/static-analysis/spawn-helper.ts create mode 100644 src/services/static-analysis/types.ts diff --git a/cli/commands/evaluate-command.ts b/cli/commands/evaluate-command.ts index 21d2223..6daed63 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'; @@ -170,6 +171,7 @@ export async function runEvaluateCommand(args: string[]) { } else if (!commitHash) { commitHash = generateDiffHash(diff); } + const filesChanged = extractFilesFromDiff(diff); // Create agent registry with all agents const agentRegistry = createAgentRegistry(config); @@ -177,7 +179,7 @@ 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 config, }; diff --git a/cli/mcp-server.ts b/cli/mcp-server.ts index 601b327..3b7b4d4 100644 --- a/cli/mcp-server.ts +++ b/cli/mcp-server.ts @@ -9,6 +9,11 @@ */ 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) => { 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/config-loader.ts b/src/config/config-loader.ts index a944f76..25c5d67 100644 --- a/src/config/config-loader.ts +++ b/src/config/config-loader.ts @@ -22,14 +22,49 @@ 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: true, + excludedPaths: [], + semgrep: { + enabled: true, + rules: ['auto'], + timeout: 60000, + }, + qlty: { + enabled: true, + timeout: 60000, + }, + 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, + semgrep: { + ...staticAnalysisDefaults.semgrep, + ...(userConfig.staticAnalysis.semgrep || {}), + }, + qlty: { + ...staticAnalysisDefaults.qlty, + ...(userConfig.staticAnalysis.qlty || {}), + }, + } + : staticAnalysisDefaults, }; return merged; 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..af41f9f 100644 --- a/src/config/default-config.ts +++ b/src/config/default-config.ts @@ -52,6 +52,36 @@ export const DEFAULT_CONFIG: AppConfig = { excludePatterns: ['node_modules/**', 'dist/**', '.git/**', 'coverage/**'], chunkSize: 1000, }, + staticAnalysis: { + enabled: true, + autoInstall: true, + excludedPaths: [ + '.git/**', + 'node_modules/**', + '.venv/**', + 'venv/**', + 'env/**', + 'dist/**', + 'build/**', + 'out/**', + 'coverage/**', + '.next/**', + '.nuxt/**', + '.cache/**', + '.turbo/**', + ], + semgrep: { + enabled: true, + rules: ['auto'], + timeout: 60000, + }, + qlty: { + enabled: true, + timeout: 60000, + }, + maxFindings: 500, + minSeverity: 'warning', + }, }; // For backwards compatibility diff --git a/src/mcp-server/tools/handlers.ts b/src/mcp-server/tools/handlers.ts index d28a2c2..e4c4ebf 100644 --- a/src/mcp-server/tools/handlers.ts +++ b/src/mcp-server/tools/handlers.ts @@ -12,7 +12,7 @@ import { DEFAULT_CONFIG } from '../../config/default-config'; import { AppConfig } from '../../config/config.interface'; import { createAgentRegistry, getEvaluationRoot, saveEvaluationReports, createEvaluationDirectory } from '../../../cli/utils/shared.utils'; import { CommitEvaluationOrchestrator } from '../../orchestrator/commit-evaluation-orchestrator'; -import { getCommitDiff, extractCommitHash, generateDiffHash } from '../../../cli/utils/git-utils'; +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'; @@ -596,6 +596,7 @@ export const handleEvaluateCommit: ContextualToolHandler = async ( 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`); @@ -646,7 +647,7 @@ export const handleEvaluateCommit: ContextualToolHandler = async ( const evalContext = { commitDiff: diff, - filesChanged: [], + filesChanged, commitHash: resolvedHash, config, }; 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..f770a48 --- /dev/null +++ b/src/services/static-analysis/__tests__/spawn-helper.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { spawnAsync } from '../spawn-helper'; + +describe('spawnAsync', () => { + it('should capture stdout from a successful command', async () => { + const result = await spawnAsync('echo', ['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('sh', ['-c', 'echo error >&2']); + 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('sh', ['-c', '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('sleep', ['10'], { timeout: 200 }); + expect(result.timedOut).toBe(true); + expect(result.exitCode).toBeNull(); + }, 10_000); + + it('should respect cwd option', async () => { + const result = await spawnAsync('pwd', [], { cwd: '/tmp' }); + // macOS may resolve /tmp to /private/tmp + expect(result.stdout.trim()).toMatch(/\/tmp$/); + expect(result.exitCode).toBe(0); + }); + + it('should handle large output without crashing', async () => { + // Generate ~100KB of output + const result = await spawnAsync('sh', [ + '-c', + 'for i in $(seq 1 1000); do echo "line $i: padding data for buffer test"; done', + ]); + expect(result.stdout.length).toBeGreaterThan(10000); + expect(result.exitCode).toBe(0); + }); + + it('should truncate output at maxBuffer', async () => { + const result = await spawnAsync( + 'sh', + ['-c', 'for i in $(seq 1 10000); do echo "line $i"; done'], + { maxBuffer: 1024 } + ); + expect(result.stdout.length).toBeLessThanOrEqual(1024); + expect(result.exitCode).toBe(0); + }); +}); 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/types.ts b/src/services/static-analysis/types.ts new file mode 100644 index 0000000..cbdb460 --- /dev/null +++ b/src/services/static-analysis/types.ts @@ -0,0 +1,159 @@ +// --- Enums / Unions ------------------------------------------------- + +export type FindingSeverity = 'error' | 'warning' | 'info'; +export type FindingSource = 'semgrep' | 'qlty'; +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; + 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; + totalMs: number; + }; + counts: { + total: number; + errors: number; + warnings: number; + info: number; + byCategory: Record; + byFile: 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; + }; +} + +// --- Config ---------------------------------------------------------- + +export interface StaticAnalysisConfig { + enabled: boolean; + /** Allow auto-installing missing tools */ + autoInstall: 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; + }; + /** 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; + }; + }>; +} From 2c957eeeaaae16cb2f5c65043e4e4375d3e7b201 Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Fri, 13 Feb 2026 12:08:50 +0100 Subject: [PATCH 03/24] feat: add file-scope resolver and tool installer for static analysis --- package-lock.json | 2 +- package.json | 1 + .../__tests__/file-scope-resolver.test.ts | 81 ++++ .../__tests__/spawn-helper.test.ts | 37 +- .../__tests__/tool-installer.test.ts | 155 +++++++ .../static-analysis/file-scope-resolver.ts | 108 +++++ .../static-analysis/tool-installer.ts | 421 ++++++++++++++++++ 7 files changed, 788 insertions(+), 17 deletions(-) create mode 100644 src/services/static-analysis/__tests__/file-scope-resolver.test.ts create mode 100644 src/services/static-analysis/__tests__/tool-installer.test.ts create mode 100644 src/services/static-analysis/file-scope-resolver.ts create mode 100644 src/services/static-analysis/tool-installer.ts diff --git a/package-lock.json b/package-lock.json index 2e78372..a689702 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "langchain": "^1.1.2", "ora": "^5.4.1", "p-limit": "^5.0.0", + "picomatch": "^2.3.1", "table": "^6.9.0" }, "bin": { @@ -4887,7 +4888,6 @@ "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" diff --git a/package.json b/package.json index 9ac6176..5260abe 100644 --- a/package.json +++ b/package.json @@ -113,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/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..951927e --- /dev/null +++ b/src/services/static-analysis/__tests__/file-scope-resolver.test.ts @@ -0,0 +1,81 @@ +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, '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, '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('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__/spawn-helper.test.ts b/src/services/static-analysis/__tests__/spawn-helper.test.ts index f770a48..50b654c 100644 --- a/src/services/static-analysis/__tests__/spawn-helper.test.ts +++ b/src/services/static-analysis/__tests__/spawn-helper.test.ts @@ -1,22 +1,23 @@ 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('echo', ['hello world']); + 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('sh', ['-c', 'echo error >&2']); + 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('sh', ['-c', 'exit 42']); + const result = await spawnAsync(process.execPath, ['-e', 'process.exit(42)']); expect(result.exitCode).toBe(42); expect(result.timedOut).toBe(false); }); @@ -29,34 +30,38 @@ describe('spawnAsync', () => { }); it('should kill process on timeout', async () => { - const result = await spawnAsync('sleep', ['10'], { timeout: 200 }); + 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 result = await spawnAsync('pwd', [], { cwd: '/tmp' }); - // macOS may resolve /tmp to /private/tmp - expect(result.stdout.trim()).toMatch(/\/tmp$/); + 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 () => { - // Generate ~100KB of output - const result = await spawnAsync('sh', [ - '-c', - 'for i in $(seq 1 1000); do echo "line $i: padding data for buffer test"; done', + 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( - 'sh', - ['-c', 'for i in $(seq 1 10000); do echo "line $i"; done'], - { maxBuffer: 1024 } - ); + 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__/tool-installer.test.ts b/src/services/static-analysis/__tests__/tool-installer.test.ts new file mode 100644 index 0000000..1f8e352 --- /dev/null +++ b/src/services/static-analysis/__tests__/tool-installer.test.ts @@ -0,0 +1,155 @@ +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 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(true); + + expect(findSpy).toHaveBeenCalledTimes(2); + expect(second).toBe(first); + }); + + 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/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/tool-installer.ts b/src/services/static-analysis/tool-installer.ts new file mode 100644 index 0000000..ebdea3d --- /dev/null +++ b/src/services/static-analysis/tool-installer.ts @@ -0,0 +1,421 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import * as childProcess from 'child_process'; +import { LoggerService } from '../../common/logger'; +import { ToolAvailability, ToolStatus } from './types'; + +const STATIC_ANALYSIS_LOG_CATEGORY = 'static-analysis'; + +interface InstallResult { + success: boolean; + executable: string | null; + error?: string; +} + +type CommandRunner = ( + command: string, + args: string[] +) => childProcess.SpawnSyncReturns; + +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; + private static cachedAvailability: ToolAvailability | null = null; + private static inFlightAvailability: Promise | null = null; + + /** + * Test helper to clear process-level cache between tests. + */ + static resetForTests(): void { + ToolInstaller.cachedAvailability = null; + ToolInstaller.inFlightAvailability = null; + 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 { + const systemExecutable = ToolInstaller.findInSystemPath(toolName); + if (systemExecutable && ToolInstaller.isToolInstalled(systemExecutable).installed) { + return systemExecutable; + } + + const managedExecutable = ToolInstaller.getManagedExecutablePath(toolName); + if (ToolInstaller.fileExists(managedExecutable) && ToolInstaller.isToolInstalled(managedExecutable).installed) { + return managedExecutable; + } + + return null; + } + + static isToolInstalled(executablePath: string): { installed: boolean; version?: string } { + const result = childProcess.spawnSync(executablePath, ['--version'], { + stdio: 'pipe', + encoding: 'utf-8', + }); + + if (result.error || result.status !== 0) { + return { installed: false }; + } + + const version = ToolInstaller.extractVersion(result.stdout, result.stderr); + return version ? { installed: true, version } : { installed: true }; + } + + static installSemgrep(): InstallResult { + LoggerService.info('Semgrep not found. Attempting install...', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + + ToolInstaller.ensureDirectory(ToolInstaller.getBinDir()); + + const venvPath = ToolInstaller.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 = ToolInstaller.runCommand(pythonCmd, ['-m', 'venv', venvPath]); + if (createResult.status === 0 && !createResult.error) { + venvCreated = true; + break; + } + venvCreateError = ToolInstaller.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 = ToolInstaller.runCommand(pipPath, ['install', 'semgrep']); + if (installResult.status === 0 && !installResult.error && ToolInstaller.fileExists(semgrepPath)) { + const managedExecutable = ToolInstaller.getManagedExecutablePath('semgrep'); + ToolInstaller.linkOrCopyExecutable(semgrepPath, managedExecutable); + + if (ToolInstaller.isToolInstalled(managedExecutable).installed) { + LoggerService.info(`Semgrep installed at ${managedExecutable}`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { success: true, executable: managedExecutable }; + } + + if (ToolInstaller.isToolInstalled(semgrepPath).installed) { + LoggerService.info(`Semgrep installed at ${semgrepPath}`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { success: true, executable: semgrepPath }; + } + } + + const installError = ToolInstaller.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 = ToolInstaller.installSemgrepWithUserPip(); + if (fallback.success) { + return fallback; + } + + return { + success: false, + executable: null, + error: fallback.error || venvCreateError || 'Failed to install Semgrep', + }; + } + + static installQlty(): InstallResult { + LoggerService.info('Qlty not found. Attempting install...', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + + ToolInstaller.ensureDirectory(ToolInstaller.getBinDir()); + + const installCommand = process.platform === 'win32' + ? { + cmd: 'powershell', + args: [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + 'iwr -useb https://qlty.sh | iex', + ], + } + : { + cmd: 'sh', + args: ['-c', 'curl -fsSL https://qlty.sh | sh'], + }; + + const installResult = ToolInstaller.runCommand(installCommand.cmd, installCommand.args); + if (installResult.status !== 0 || installResult.error) { + return { + success: false, + executable: null, + error: ToolInstaller.formatCommandError(installCommand.cmd, installCommand.args, installResult), + }; + } + + const discoveredExecutable = ToolInstaller.findExecutable('qlty') + || ToolInstaller.findExecutableAt(path.join(os.homedir(), '.qlty', 'bin', ToolInstaller.binaryName('qlty'))) + || ToolInstaller.findExecutableAt(path.join(os.homedir(), '.local', 'bin', ToolInstaller.binaryName('qlty'))); + + if (!discoveredExecutable) { + return { + success: false, + executable: null, + error: 'Qlty installer completed, but executable was not found in PATH or known install locations.', + }; + } + + const managedExecutable = ToolInstaller.getManagedExecutablePath('qlty'); + ToolInstaller.linkOrCopyExecutable(discoveredExecutable, managedExecutable); + if (ToolInstaller.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 }; + } + + static async ensureAvailable(autoInstall: boolean): Promise { + if (ToolInstaller.cachedAvailability) { + return ToolInstaller.cachedAvailability; + } + + if (ToolInstaller.inFlightAvailability) { + return ToolInstaller.inFlightAvailability; + } + + ToolInstaller.inFlightAvailability = Promise.resolve(ToolInstaller.computeAvailability(autoInstall)) + .then((availability) => { + ToolInstaller.cachedAvailability = availability; + return availability; + }) + .finally(() => { + ToolInstaller.inFlightAvailability = null; + }); + + return ToolInstaller.inFlightAvailability; + } + + private static computeAvailability(autoInstall: boolean): ToolAvailability { + return { + semgrep: ToolInstaller.resolveTool('semgrep', autoInstall), + qlty: ToolInstaller.resolveTool('qlty', autoInstall), + }; + } + + private static resolveTool(toolName: 'semgrep' | 'qlty', autoInstall: boolean): ToolStatus { + const existing = ToolInstaller.findExecutable(toolName); + if (existing) { + return { + available: true, + executable: existing, + installedNow: false, + }; + } + + if (!autoInstall) { + return { + available: false, + executable: null, + installedNow: false, + error: `${toolName} is not available and auto-install is disabled.`, + }; + } + + const installResult = toolName === 'semgrep' + ? ToolInstaller.installSemgrep() + : ToolInstaller.installQlty(); + + if (installResult.success && installResult.executable) { + return { + available: true, + executable: installResult.executable, + installedNow: true, + }; + } + + return { + available: false, + executable: null, + installedNow: false, + error: installResult.error || `Failed to install ${toolName}`, + }; + } + + private static installSemgrepWithUserPip(): 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 = ToolInstaller.runCommand(fallback.cmd, fallback.args); + if (result.status === 0 && !result.error) { + const detected = ToolInstaller.findExecutable('semgrep'); + if (detected) { + LoggerService.info(`Semgrep installed via fallback at ${detected}`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return { success: true, executable: detected }; + } + } + lastError = ToolInstaller.formatCommandError(fallback.cmd, fallback.args, result); + } + + return { + success: false, + executable: null, + error: lastError || 'Fallback semgrep install failed', + }; + } + + private static findInSystemPath(toolName: string): string | null { + const lookupCommand = process.platform === 'win32' ? 'where' : 'which'; + const result = ToolInstaller.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 (ToolInstaller.findExecutableAt(candidate)) { + return candidate; + } + } + + return null; + } + + private static runCommand(command: string, args: string[]): childProcess.SpawnSyncReturns { + return ToolInstaller.commandRunner(command, args); + } + + private static extractVersion(stdout?: string, stderr?: string): string | undefined { + const combined = `${stdout || ''}\n${stderr || ''}` + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + return combined || undefined; + } + + private static 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'}`; + } + + private static ensureDirectory(targetDir: string): void { + fs.mkdirSync(targetDir, { recursive: true }); + } + + private static fileExists(targetPath: string): boolean { + try { + return fs.existsSync(targetPath); + } catch { + return false; + } + } + + private static findExecutableAt(targetPath: string): string | null { + return ToolInstaller.fileExists(targetPath) ? targetPath : null; + } + + private static linkOrCopyExecutable(sourcePath: string, targetPath: string): void { + ToolInstaller.ensureDirectory(path.dirname(targetPath)); + if (ToolInstaller.fileExists(targetPath)) { + fs.rmSync(targetPath, { force: true }); + } + + try { + fs.symlinkSync(sourcePath, targetPath); + } catch { + fs.copyFileSync(sourcePath, targetPath); + fs.chmodSync(targetPath, 0o755); + } + } + + private 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'); + } + + private static getBinDir(): string { + return path.join(ToolInstaller.getToolsDir(), 'bin'); + } + + private static getVenvDir(): string { + return path.join(ToolInstaller.getToolsDir(), 'semgrep-venv'); + } + + private static getManagedExecutablePath(toolName: string): string { + return path.join(ToolInstaller.getBinDir(), ToolInstaller.binaryName(toolName)); + } + + private static binaryName(toolName: string): string { + return process.platform === 'win32' ? `${toolName}.exe` : toolName; + } +} From 93e82f5ad5e7fda6a9db69dccb4cee02d4db4140 Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Fri, 13 Feb 2026 17:41:25 +0100 Subject: [PATCH 04/24] feat(static-analysis): implement semgrep integration, scope filtering, and warning handling --- cli/commands/batch-evaluate-command.ts | 3 + cli/commands/evaluate-command.ts | 27 +- cli/utils/shared.utils.ts | 80 ++- src/config/config-loader.ts | 23 +- src/config/default-config.ts | 7 + src/mcp-server/tools/handlers.ts | 25 + .../commit-evaluation-orchestrator.ts | 37 +- .../__tests__/file-scope-resolver.test.ts | 38 ++ .../__tests__/semgrep-runner.test.ts | 291 +++++++++++ .../__tests__/static-analysis.service.test.ts | 284 ++++++++++ src/services/static-analysis/index.ts | 6 + .../static-analysis/semgrep-runner.ts | 274 ++++++++++ .../static-analysis.service.ts | 488 ++++++++++++++++++ src/services/static-analysis/types.ts | 4 + 14 files changed, 1579 insertions(+), 8 deletions(-) create mode 100644 src/services/static-analysis/__tests__/semgrep-runner.test.ts create mode 100644 src/services/static-analysis/__tests__/static-analysis.service.test.ts create mode 100644 src/services/static-analysis/index.ts create mode 100644 src/services/static-analysis/semgrep-runner.ts create mode 100644 src/services/static-analysis/static-analysis.service.ts 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/evaluate-command.ts b/cli/commands/evaluate-command.ts index 6daed63..81532f9 100644 --- a/cli/commands/evaluate-command.ts +++ b/cli/commands/evaluate-command.ts @@ -164,10 +164,24 @@ 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); } @@ -181,6 +195,7 @@ export async function runEvaluateCommand(args: string[]) { commitDiff: diff, filesChanged, commitHash, // Add commit hash to context for logging + repoPath, config, }; @@ -315,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 @@ -336,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', @@ -381,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/utils/shared.utils.ts b/cli/utils/shared.utils.ts index 7cbf768..adc9198 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,52 @@ 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; +}> { + 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; + } = { + summary: 'static-analysis/summary.json', + findings: 'static-analysis/findings.json', + }; + + if (rawOutputs.semgrep) { + await fs.writeFile( + path.join(staticDir, 'raw-semgrep.json'), + JSON.stringify(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(rawOutputs.qlty, null, 2)); + references.rawQlty = 'static-analysis/raw-qlty.json'; + } + + return references; +} + /** * 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/src/config/config-loader.ts b/src/config/config-loader.ts index 25c5d67..35d70d3 100644 --- a/src/config/config-loader.ts +++ b/src/config/config-loader.ts @@ -27,7 +27,28 @@ export function loadConfig(configPath?: string): AppConfig | null { const staticAnalysisDefaults: NonNullable = DEFAULT_CONFIG.staticAnalysis ?? { enabled: true, autoInstall: true, - excludedPaths: [], + excludedPaths: [ + '.git/**', + 'node_modules/**', + '.venv/**', + 'venv/**', + 'env/**', + 'dist/**', + 'build/**', + 'out/**', + 'coverage/**', + '.next/**', + '.nuxt/**', + '.cache/**', + '.turbo/**', + '**/__tests__/**', + '**/test/**', + '**/tests/**', + '**/*.test.*', + '**/*.spec.*', + '**/*_test.*', + '**/*_spec.*', + ], semgrep: { enabled: true, rules: ['auto'], diff --git a/src/config/default-config.ts b/src/config/default-config.ts index af41f9f..e781747 100644 --- a/src/config/default-config.ts +++ b/src/config/default-config.ts @@ -69,6 +69,13 @@ export const DEFAULT_CONFIG: AppConfig = { '.nuxt/**', '.cache/**', '.turbo/**', + '**/__tests__/**', + '**/test/**', + '**/tests/**', + '**/*.test.*', + '**/*.spec.*', + '**/*_test.*', + '**/*_spec.*', ], semgrep: { enabled: true, diff --git a/src/mcp-server/tools/handlers.ts b/src/mcp-server/tools/handlers.ts index e4c4ebf..9a0ac9a 100644 --- a/src/mcp-server/tools/handlers.ts +++ b/src/mcp-server/tools/handlers.ts @@ -299,6 +299,17 @@ export function buildInlineConfig( }, 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 }, + excludedPaths: [...DEFAULT_CONFIG.staticAnalysis.excludedPaths], + } + : undefined, }; } @@ -649,6 +660,7 @@ export const handleEvaluateCommit: ContextualToolHandler = async ( commitDiff: diff, filesChanged, commitHash: resolvedHash, + repoPath: projectPath, config, }; @@ -660,6 +672,7 @@ export const handleEvaluateCommit: ContextualToolHandler = async ( orchestrator.evaluateCommit(evalContext, { streaming: false, threadId: `mcp-eval-${Date.now()}`, + outputMode: 'mcp', }) ); } catch (evalError) { @@ -752,6 +765,17 @@ export const handleEvaluateCommit: ContextualToolHandler = async ( })), }; + 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 // ======================================================================== @@ -800,6 +824,7 @@ export const handleEvaluateCommit: ContextualToolHandler = async ( metadata, diff, developerOverview: evaluationResult.developerOverview, + staticAnalysis: evaluationResult.staticAnalysis, }); response.savedTo = `.evaluated-commits/${resolvedHash}`; diff --git a/src/orchestrator/commit-evaluation-orchestrator.ts b/src/orchestrator/commit-evaluation-orchestrator.ts index d55b9ea..d2574ff 100644 --- a/src/orchestrator/commit-evaluation-orchestrator.ts +++ b/src/orchestrator/commit-evaluation-orchestrator.ts @@ -2,6 +2,14 @@ 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'; export class CommitEvaluationOrchestrator { constructor( @@ -81,7 +89,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 +131,26 @@ 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 + ); + StaticAnalysisService.logOutput(staticAnalysis.summary, outputMode); + } 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), + }, + }); + } + } + // Initialize graph state const initialState = { commitDiff: context.commitDiff, @@ -259,6 +293,7 @@ export class CommitEvaluationOrchestrator { totalInputTokens: finalState?.totalInputTokens, totalOutputTokens: finalState?.totalOutputTokens, totalCost: finalState?.totalCost, + staticAnalysis, }; } diff --git a/src/services/static-analysis/__tests__/file-scope-resolver.test.ts b/src/services/static-analysis/__tests__/file-scope-resolver.test.ts index 951927e..52559fb 100644 --- a/src/services/static-analysis/__tests__/file-scope-resolver.test.ts +++ b/src/services/static-analysis/__tests__/file-scope-resolver.test.ts @@ -13,10 +13,18 @@ describe('FileScopeResolver', () => { 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()); @@ -63,6 +71,36 @@ describe('FileScopeResolver', () => { 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, []); 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..1192f01 --- /dev/null +++ b/src/services/static-analysis/__tests__/semgrep-runner.test.ts @@ -0,0 +1,291 @@ +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', + 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__/static-analysis.service.test.ts b/src/services/static-analysis/__tests__/static-analysis.service.test.ts new file mode 100644 index 0000000..a12c59b --- /dev/null +++ b/src/services/static-analysis/__tests__/static-analysis.service.test.ts @@ -0,0 +1,284 @@ +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 { 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, + excludedPaths: [], + semgrep: { + enabled: true, + rules: ['auto'], + timeout: 30_000, + }, + qlty: { + enabled: false, + timeout: 30_000, + }, + maxFindings: 500, + minSeverity: 'warning', + ...overrides, + }; +} + +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 on resolved commit files and returns findings/raw output', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ + results: [ + { + check_id: 'security.xss.rule', + path: 'src/app.ts', + start: { line: 2, col: 4 }, + extra: { + message: 'Potential XSS', + severity: 'ERROR', + metadata: { category: 'security' }, + }, + }, + ], + }), + stderr: '', + exitCode: 1, + timedOut: false, + }); + + const result = await StaticAnalysisService.analyze( + ['src/app.ts'], + repoRoot, + createConfig() + ); + + 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.findings[0]).toMatchObject({ + source: 'semgrep', + file: 'src/app.ts', + severity: 'error', + ruleId: 'security.xss.rule', + }); + expect(result.rawOutputs.semgrep?.exitCode).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('surfaces Semgrep engine warnings from top-level errors', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ + results: [], + errors: [ + { + rule_id: 'javascript.some.rule', + path: 'src/app.ts', + message: 'Internal matching error: engine limitation', + }, + ], + }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const result = await StaticAnalysisService.analyze( + ['src/app.ts'], + repoRoot, + createConfig() + ); + + expect(result.summary.warnings.some((warning) => warning.includes('Semgrep engine warning'))).toBe( + true + ); + expect( + result.summary.warnings.some((warning) => warning.includes('javascript.some.rule')) + ).toBe(true); + }); + + it('suppresses Pro-engine-only Semgrep warnings while keeping actionable warnings', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ + results: [], + errors: [ + { + rule_id: 'javascript.pro.only', + path: 'src/app.ts', + message: 'metavariable-name:module(s) operator is only supported in the Pro engine', + }, + { + rule_id: 'javascript.actionable', + path: 'src/app.ts', + message: 'Internal matching error: parser timeout', + }, + ], + }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const result = await StaticAnalysisService.analyze( + ['src/app.ts'], + repoRoot, + createConfig() + ); + + expect( + result.summary.warnings.some((warning) => warning.includes('javascript.pro.only')) + ).toBe(false); + expect( + result.summary.warnings.some((warning) => warning.includes('javascript.actionable')) + ).toBe(true); + }); + + it('always excludes test files regardless of configured excludedPaths', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ results: [] }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const result = await StaticAnalysisService.analyze( + ['src/app.ts', 'src/app.test.ts'], + repoRoot, + createConfig({ excludedPaths: [] }) + ); + + expect(mockedSpawnAsync).toHaveBeenCalledWith( + '/usr/bin/semgrep', + ['scan', '--json', '--quiet', '--oss-only', '--config', 'auto', 'src/app.ts'], + { cwd: repoRoot, timeout: 30_000 } + ); + expect(result.summary.skippedFiles.excluded).toContain('src/app.test.ts'); + }); + + 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('applies min severity filter and max findings cap', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ + results: [ + { + check_id: 'rule.info', + path: 'src/app.ts', + start: { line: 1, col: 1 }, + extra: { message: 'info', severity: 'INFO', metadata: {} }, + }, + { + check_id: 'rule.error.one', + path: 'src/app.ts', + start: { line: 2, col: 1 }, + extra: { message: 'err1', severity: 'ERROR', metadata: {} }, + }, + { + check_id: 'rule.error.two', + path: 'src/app.ts', + start: { line: 3, col: 1 }, + extra: { message: 'err2', severity: 'ERROR', metadata: {} }, + }, + ], + }), + stderr: '', + exitCode: 1, + timedOut: false, + }); + + const result = await StaticAnalysisService.analyze( + ['src/app.ts'], + repoRoot, + createConfig({ + 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('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/index.ts b/src/services/static-analysis/index.ts new file mode 100644 index 0000000..e27545c --- /dev/null +++ b/src/services/static-analysis/index.ts @@ -0,0 +1,6 @@ +export * from './types'; +export * from './spawn-helper'; +export * from './file-scope-resolver'; +export * from './tool-installer'; +export * from './semgrep-runner'; +export * from './static-analysis.service'; diff --git a/src/services/static-analysis/semgrep-runner.ts b/src/services/static-analysis/semgrep-runner.ts new file mode 100644 index 0000000..9905184 --- /dev/null +++ b/src/services/static-analysis/semgrep-runner.ts @@ -0,0 +1,274 @@ +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', + 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/static-analysis.service.ts b/src/services/static-analysis/static-analysis.service.ts new file mode 100644 index 0000000..95e35cb --- /dev/null +++ b/src/services/static-analysis/static-analysis.service.ts @@ -0,0 +1,488 @@ +import { LoggerService } from '../../common/logger'; +import { FileScopeResolver } from './file-scope-resolver'; +import { SemgrepRunner } from './semgrep-runner'; +import { ToolInstaller } from './tool-installer'; +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.*', +]; + +export type StaticAnalysisOutputMode = 'single' | 'batch' | 'mcp'; + +export interface StaticAnalysisResult { + summary: StaticAnalysisSummary; + rawOutputs: RawToolOutput; +} + +export class StaticAnalysisService { + static async analyze( + filesChanged: string[], + repoPath: string, + config: StaticAnalysisConfig + ): 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 { + availability = await ToolInstaller.ensureAvailable(config.autoInstall); + } catch (error) { + warnings.push( + `Tool availability check failed: ${error instanceof Error ? error.message : String(error)}` + ); + } finally { + toolAvailabilityMs = Date.now() - availabilityStart; + } + + const semgrepRunnable = Boolean( + config.semgrep.enabled && availability.semgrep.available && availability.semgrep.executable + ); + // Qlty runner is intentionally not wired yet in this phase. + const qltyRunnable = false; + effectiveAvailability = { + semgrep: semgrepRunnable, + qlty: qltyRunnable, + }; + + if (config.semgrep.enabled && !availability.semgrep.available) { + warnings.push( + availability.semgrep.error || 'Semgrep is not available; semgrep scan skipped.' + ); + } + if (config.qlty.enabled && availability.qlty.available) { + warnings.push('Qlty runner is not implemented yet; qlty scan skipped.'); + } + + 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 runners: IToolRunner[] = []; + if (semgrepRunnable && availability.semgrep.executable) { + runners.push(new SemgrepRunner(availability.semgrep.executable, config.semgrep.rules)); + } + + if (runners.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: number | null = null; + let qltyMs: number | null = null; + const allFindings: StaticAnalysisFinding[] = []; + + for (const runner of runners) { + const toolStart = Date.now(); + try { + const timeout = runner.name === 'semgrep' ? config.semgrep.timeout : config.qlty.timeout; + const result = await runner.scan(scope.scanFiles, repoPath, timeout); + allFindings.push(...result.findings); + + if (runner.name === 'semgrep') { + semgrepMs = Date.now() - toolStart; + rawOutputs.semgrep = result.rawOutput; + warnings.push(...this.extractSemgrepEngineWarnings(result.rawOutput.stdout)); + } else if (runner.name === 'qlty') { + qltyMs = Date.now() - toolStart; + rawOutputs.qlty = result.rawOutput; + } + } catch (error) { + warnings.push( + `${runner.name} scan failed: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + const postProcessStart = Date.now(); + const deduplicated = this.deduplicate(allFindings); + 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 runnerMs = (semgrepMs || 0) + (qltyMs || 0); + const overheadMs = Math.max( + 0, + totalMs - toolAvailabilityMs - scopeResolutionMs - postProcessMs - runnerMs + ); + + const summary: StaticAnalysisSummary = { + available: effectiveAvailability, + timing: { + semgrepMs, + qltyMs, + 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, + }, + } + ); + + for (const warning of summary.warnings) { + LoggerService.warn(warning, { category: STATIC_ANALYSIS_LOG_CATEGORY }); + } + + if (outputMode !== 'single') { + return; + } + + for (const finding of summary.findings) { + LoggerService.info( + `[${finding.severity}][${finding.category}][${finding.source}] ${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: {}, + }, + 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 deduplicate(findings: StaticAnalysisFinding[]): StaticAnalysisFinding[] { + const unique = new Map(); + for (const finding of findings) { + const key = [ + 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, + }; + + 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; + } + + return counts; + } +} diff --git a/src/services/static-analysis/types.ts b/src/services/static-analysis/types.ts index cbdb460..bdf825b 100644 --- a/src/services/static-analysis/types.ts +++ b/src/services/static-analysis/types.ts @@ -75,6 +75,10 @@ export interface StaticAnalysisSummary { timing: { semgrepMs: number | null; qltyMs: number | null; + toolAvailabilityMs?: number; + scopeResolutionMs?: number; + postProcessMs?: number; + overheadMs?: number; totalMs: number; }; counts: { From 2a9ea3bddaebd0e37240375b32100c5e298dbfe3 Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Mon, 16 Feb 2026 14:56:14 +0100 Subject: [PATCH 05/24] feat(config): fail fast on invalid staticAnalysis config --- cli/index.ts | 40 +++ docs/CONFIGURATION.md | 59 +++++ .../config-loader.validation.test.ts | 71 ++++++ src/config/__tests__/config-validator.test.ts | 133 ++++++++++ src/config/config-loader.ts | 7 + src/config/config-validator.ts | 232 ++++++++++++++++++ src/mcp-server/tools/handlers.ts | 68 ++++- 7 files changed, 608 insertions(+), 2 deletions(-) create mode 100644 src/config/__tests__/config-loader.validation.test.ts create mode 100644 src/config/__tests__/config-validator.test.ts create mode 100644 src/config/config-validator.ts 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/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 0cff1d1..6c6a607 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -382,6 +382,65 @@ 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. + +**Constraints:** + +- `staticAnalysis.enabled`: boolean +- `staticAnalysis.autoInstall`: 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.maxFindings`: integer in `[1, 5000]` +- `staticAnalysis.minSeverity`: one of `error`, `warning`, `info` + +**Valid example:** + +```json +{ + "staticAnalysis": { + "enabled": true, + "autoInstall": false, + "excludedPaths": ["node_modules/**", "dist/**"], + "semgrep": { + "enabled": true, + "rules": ["auto"], + "timeout": 60000 + }, + "qlty": { + "enabled": false, + "timeout": 60000 + }, + "maxFindings": 500, + "minSeverity": "warning" + } +} +``` + +**Invalid example (will fail):** + +```json +{ + "staticAnalysis": { + "enabled": "yes", + "semgrep": { + "enabled": true, + "rules": [], + "timeout": 0 + }, + "maxFindings": 10000, + "minSeverity": "critical" + } +} +``` + --- ## LLM Provider Setup 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..3976549 --- /dev/null +++ b/src/config/__tests__/config-loader.validation.test.ts @@ -0,0 +1,71 @@ +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 { 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, + }, + }, + }) + ); + + const loaded = loadConfig(configPath); + expect(loaded).not.toBeNull(); + expect(loaded?.staticAnalysis?.semgrep.timeout).toBe(30000); + }); + + 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/ + ); + }); +}); diff --git a/src/config/__tests__/config-validator.test.ts b/src/config/__tests__/config-validator.test.ts new file mode 100644 index 0000000..f866b05 --- /dev/null +++ b/src/config/__tests__/config-validator.test.ts @@ -0,0 +1,133 @@ +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; + } + + const error = expectValidationError(config); + expect(error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: 'staticAnalysis.semgrep.timeout', + }), + expect.objectContaining({ + path: 'staticAnalysis.qlty.timeout', + }), + ]) + ); + }); + + 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 35d70d3..00d662e 100644 --- a/src/config/config-loader.ts +++ b/src/config/config-loader.ts @@ -5,6 +5,7 @@ import fs from 'fs'; import path from 'path'; import { DEFAULT_CONFIG } from './default-config'; import { AppConfig } from './config.interface'; +import { ConfigValidationError, validateConfig } from './config-validator'; /** * Load configuration from file @@ -88,8 +89,14 @@ export function loadConfig(configPath?: string): AppConfig | null { : 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..3c37893 --- /dev/null +++ b/src/config/config-validator.ts @@ -0,0 +1,232 @@ +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); + 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 + ); + + 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/mcp-server/tools/handlers.ts b/src/mcp-server/tools/handlers.ts index 9a0ac9a..1e492c1 100644 --- a/src/mcp-server/tools/handlers.ts +++ b/src/mcp-server/tools/handlers.ts @@ -10,6 +10,7 @@ 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'; @@ -159,6 +160,22 @@ export function createValidationErrorResponse(errors: ValidationError[]): ToolRe }); } +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 // ============================================================================ @@ -524,7 +541,28 @@ export const handleEvaluateCommit: ContextualToolHandler = async ( configSource = 'file'; context.logger.info(`Using CONFIG FILE: ${configFilePath}`); - const loadedConfig = loadConfig(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.', { @@ -2221,6 +2259,11 @@ export const handleCheckConfig: ContextualToolHandler = async ( 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', @@ -2971,7 +3014,28 @@ export const handleGenerateOkr: ContextualToolHandler = async ( configSource = 'file'; context.logger.info(`Using CONFIG FILE: ${configFilePath}`); - const loadedConfig = loadConfig(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', From e0762ad82dd5932ae04d61bd6bebb1c28c4e96e4 Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Mon, 16 Feb 2026 15:07:50 +0100 Subject: [PATCH 06/24] fix(static-analysis): cache tool availability per autoInstall mode --- .../__tests__/tool-installer.test.ts | 28 +++++++++++++- .../static-analysis/tool-installer.ts | 37 +++++++++++++------ 2 files changed, 51 insertions(+), 14 deletions(-) diff --git a/src/services/static-analysis/__tests__/tool-installer.test.ts b/src/services/static-analysis/__tests__/tool-installer.test.ts index 1f8e352..51e7782 100644 --- a/src/services/static-analysis/__tests__/tool-installer.test.ts +++ b/src/services/static-analysis/__tests__/tool-installer.test.ts @@ -102,18 +102,42 @@ describe('ToolInstaller', () => { expect(result.qlty.error).toContain('qlty install failed'); }); - it('uses cache so repeated ensureAvailable calls do not re-check tools', async () => { + 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(true); + const second = await ToolInstaller.ensureAvailable(false); expect(findSpy).toHaveBeenCalledTimes(2); expect(second).toBe(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('findExecutable resolves a tool from system path first', () => { const lookupCommand = process.platform === 'win32' ? 'where' : 'which'; const binaryName = process.platform === 'win32' ? 'semgrep.exe' : 'semgrep'; diff --git a/src/services/static-analysis/tool-installer.ts b/src/services/static-analysis/tool-installer.ts index ebdea3d..12dcc0b 100644 --- a/src/services/static-analysis/tool-installer.ts +++ b/src/services/static-analysis/tool-installer.ts @@ -19,6 +19,8 @@ type CommandRunner = ( ) => childProcess.SpawnSyncReturns; export class ToolInstaller { + private static readonly AUTO_INSTALL_CACHE_KEY = 'auto-install-enabled'; + private static readonly NO_AUTO_INSTALL_CACHE_KEY = 'auto-install-disabled'; private static readonly defaultCommandRunner: CommandRunner = (command, args) => { return childProcess.spawnSync(command, args, { stdio: 'pipe', @@ -27,15 +29,15 @@ export class ToolInstaller { }; private static commandRunner: CommandRunner = ToolInstaller.defaultCommandRunner; - private static cachedAvailability: ToolAvailability | null = null; - private static inFlightAvailability: Promise | null = null; + private static cachedAvailabilityByMode: Map = new Map(); + private static inFlightAvailabilityByMode: Map> = new Map(); /** * Test helper to clear process-level cache between tests. */ static resetForTests(): void { - ToolInstaller.cachedAvailability = null; - ToolInstaller.inFlightAvailability = null; + ToolInstaller.cachedAvailabilityByMode.clear(); + ToolInstaller.inFlightAvailabilityByMode.clear(); ToolInstaller.commandRunner = ToolInstaller.defaultCommandRunner; } @@ -212,24 +214,35 @@ export class ToolInstaller { } static async ensureAvailable(autoInstall: boolean): Promise { - if (ToolInstaller.cachedAvailability) { - return ToolInstaller.cachedAvailability; + const cacheKey = ToolInstaller.getAvailabilityCacheKey(autoInstall); + + const cachedAvailability = ToolInstaller.cachedAvailabilityByMode.get(cacheKey); + if (cachedAvailability) { + return cachedAvailability; } - if (ToolInstaller.inFlightAvailability) { - return ToolInstaller.inFlightAvailability; + const inFlightAvailability = ToolInstaller.inFlightAvailabilityByMode.get(cacheKey); + if (inFlightAvailability) { + return inFlightAvailability; } - ToolInstaller.inFlightAvailability = Promise.resolve(ToolInstaller.computeAvailability(autoInstall)) + const availabilityPromise = Promise.resolve(ToolInstaller.computeAvailability(autoInstall)) .then((availability) => { - ToolInstaller.cachedAvailability = availability; + ToolInstaller.cachedAvailabilityByMode.set(cacheKey, availability); return availability; }) .finally(() => { - ToolInstaller.inFlightAvailability = null; + ToolInstaller.inFlightAvailabilityByMode.delete(cacheKey); }); - return ToolInstaller.inFlightAvailability; + ToolInstaller.inFlightAvailabilityByMode.set(cacheKey, availabilityPromise); + return availabilityPromise; + } + + private static getAvailabilityCacheKey(autoInstall: boolean): string { + return autoInstall + ? ToolInstaller.AUTO_INSTALL_CACHE_KEY + : ToolInstaller.NO_AUTO_INSTALL_CACHE_KEY; } private static computeAvailability(autoInstall: boolean): ToolAvailability { From 2cb2cc3958b19ade272f8be7c71785e1c1ebcdfc Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Mon, 16 Feb 2026 15:31:10 +0100 Subject: [PATCH 07/24] refactor(static-analysis): split ToolInstaller internals without behavior changes --- .../static-analysis/tool-discovery.ts | 88 ++++ .../static-analysis/tool-installation.ts | 249 ++++++++++++ .../static-analysis/tool-installer-cache.ts | 49 +++ .../static-analysis/tool-installer.ts | 380 ++---------------- src/services/static-analysis/tool-paths.ts | 28 ++ 5 files changed, 450 insertions(+), 344 deletions(-) create mode 100644 src/services/static-analysis/tool-discovery.ts create mode 100644 src/services/static-analysis/tool-installation.ts create mode 100644 src/services/static-analysis/tool-installer-cache.ts create mode 100644 src/services/static-analysis/tool-paths.ts 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..e96a469 --- /dev/null +++ b/src/services/static-analysis/tool-installation.ts @@ -0,0 +1,249 @@ +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; +} + +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()); + + const installCommand = + process.platform === 'win32' + ? { + cmd: 'powershell', + args: [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + 'iwr -useb https://qlty.sh | iex', + ], + } + : { + cmd: 'sh', + args: ['-c', 'curl -fsSL https://qlty.sh | sh'], + }; + + const installResult = deps.runCommand(installCommand.cmd, installCommand.args); + if (installResult.status !== 0 || installResult.error) { + return { + success: false, + executable: null, + error: formatCommandError(installCommand.cmd, installCommand.args, installResult), + }; + } + + const discoveredExecutable = + deps.findExecutable('qlty') || + findExecutableAt(path.join(os.homedir(), '.qlty', 'bin', ToolPaths.binaryName('qlty'))) || + findExecutableAt(path.join(os.homedir(), '.local', 'bin', ToolPaths.binaryName('qlty'))); + + if (!discoveredExecutable) { + return { + success: false, + executable: null, + error: + 'Qlty installer completed, but executable was not found in PATH or known install locations.', + }; + } + + 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 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 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..eb3bdec --- /dev/null +++ b/src/services/static-analysis/tool-installer-cache.ts @@ -0,0 +1,49 @@ +import { ToolAvailability } from './types'; + +const AUTO_INSTALL_CACHE_KEY = 'auto-install-enabled'; +const NO_AUTO_INSTALL_CACHE_KEY = 'auto-install-disabled'; + +type AvailabilityComputer = () => ToolAvailability | Promise; + +export class ToolInstallerCache { + private static cachedAvailabilityByMode: Map = new Map(); + private static inFlightAvailabilityByMode: Map> = new Map(); + + static reset(): void { + ToolInstallerCache.cachedAvailabilityByMode.clear(); + ToolInstallerCache.inFlightAvailabilityByMode.clear(); + } + + static async getOrCompute( + autoInstall: boolean, + computeAvailability: AvailabilityComputer + ): Promise { + const cacheKey = ToolInstallerCache.getCacheKey(autoInstall); + + const cachedAvailability = ToolInstallerCache.cachedAvailabilityByMode.get(cacheKey); + if (cachedAvailability) { + return cachedAvailability; + } + + const inFlightAvailability = ToolInstallerCache.inFlightAvailabilityByMode.get(cacheKey); + if (inFlightAvailability) { + return inFlightAvailability; + } + + const availabilityPromise = Promise.resolve(computeAvailability()) + .then((availability) => { + ToolInstallerCache.cachedAvailabilityByMode.set(cacheKey, availability); + return availability; + }) + .finally(() => { + ToolInstallerCache.inFlightAvailabilityByMode.delete(cacheKey); + }); + + ToolInstallerCache.inFlightAvailabilityByMode.set(cacheKey, availabilityPromise); + return availabilityPromise; + } + + private static getCacheKey(autoInstall: boolean): string { + return autoInstall ? AUTO_INSTALL_CACHE_KEY : NO_AUTO_INSTALL_CACHE_KEY; + } +} diff --git a/src/services/static-analysis/tool-installer.ts b/src/services/static-analysis/tool-installer.ts index 12dcc0b..33cf635 100644 --- a/src/services/static-analysis/tool-installer.ts +++ b/src/services/static-analysis/tool-installer.ts @@ -1,26 +1,19 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; import * as childProcess from 'child_process'; -import { LoggerService } from '../../common/logger'; import { ToolAvailability, ToolStatus } from './types'; - -const STATIC_ANALYSIS_LOG_CATEGORY = 'static-analysis'; - -interface InstallResult { - success: boolean; - executable: string | null; - error?: string; -} - -type CommandRunner = ( - command: string, - args: string[] -) => childProcess.SpawnSyncReturns; +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'; export class ToolInstaller { - private static readonly AUTO_INSTALL_CACHE_KEY = 'auto-install-enabled'; - private static readonly NO_AUTO_INSTALL_CACHE_KEY = 'auto-install-disabled'; private static readonly defaultCommandRunner: CommandRunner = (command, args) => { return childProcess.spawnSync(command, args, { stdio: 'pipe', @@ -29,15 +22,12 @@ export class ToolInstaller { }; private static commandRunner: CommandRunner = ToolInstaller.defaultCommandRunner; - private static cachedAvailabilityByMode: Map = new Map(); - private static inFlightAvailabilityByMode: Map> = new Map(); /** * Test helper to clear process-level cache between tests. */ static resetForTests(): void { - ToolInstaller.cachedAvailabilityByMode.clear(); - ToolInstaller.inFlightAvailabilityByMode.clear(); + ToolInstallerCache.reset(); ToolInstaller.commandRunner = ToolInstaller.defaultCommandRunner; } @@ -49,200 +39,37 @@ export class ToolInstaller { } static findExecutable(toolName: string): string | null { - const systemExecutable = ToolInstaller.findInSystemPath(toolName); - if (systemExecutable && ToolInstaller.isToolInstalled(systemExecutable).installed) { - return systemExecutable; - } - - const managedExecutable = ToolInstaller.getManagedExecutablePath(toolName); - if (ToolInstaller.fileExists(managedExecutable) && ToolInstaller.isToolInstalled(managedExecutable).installed) { - return managedExecutable; - } - - return 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 } { - const result = childProcess.spawnSync(executablePath, ['--version'], { - stdio: 'pipe', - encoding: 'utf-8', - }); - - if (result.error || result.status !== 0) { - return { installed: false }; - } - - const version = ToolInstaller.extractVersion(result.stdout, result.stderr); - return version ? { installed: true, version } : { installed: true }; + return checkToolInstalled(executablePath); } static installSemgrep(): InstallResult { - LoggerService.info('Semgrep not found. Attempting install...', { - category: STATIC_ANALYSIS_LOG_CATEGORY, + return installSemgrepWorkflow({ + runCommand: (command, args) => ToolInstaller.runCommand(command, args), + isToolInstalled: (executablePath) => ToolInstaller.isToolInstalled(executablePath), + findExecutable: (toolName) => ToolInstaller.findExecutable(toolName), }); - - ToolInstaller.ensureDirectory(ToolInstaller.getBinDir()); - - const venvPath = ToolInstaller.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 = ToolInstaller.runCommand(pythonCmd, ['-m', 'venv', venvPath]); - if (createResult.status === 0 && !createResult.error) { - venvCreated = true; - break; - } - venvCreateError = ToolInstaller.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 = ToolInstaller.runCommand(pipPath, ['install', 'semgrep']); - if (installResult.status === 0 && !installResult.error && ToolInstaller.fileExists(semgrepPath)) { - const managedExecutable = ToolInstaller.getManagedExecutablePath('semgrep'); - ToolInstaller.linkOrCopyExecutable(semgrepPath, managedExecutable); - - if (ToolInstaller.isToolInstalled(managedExecutable).installed) { - LoggerService.info(`Semgrep installed at ${managedExecutable}`, { - category: STATIC_ANALYSIS_LOG_CATEGORY, - }); - return { success: true, executable: managedExecutable }; - } - - if (ToolInstaller.isToolInstalled(semgrepPath).installed) { - LoggerService.info(`Semgrep installed at ${semgrepPath}`, { - category: STATIC_ANALYSIS_LOG_CATEGORY, - }); - return { success: true, executable: semgrepPath }; - } - } - - const installError = ToolInstaller.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 = ToolInstaller.installSemgrepWithUserPip(); - if (fallback.success) { - return fallback; - } - - return { - success: false, - executable: null, - error: fallback.error || venvCreateError || 'Failed to install Semgrep', - }; } static installQlty(): InstallResult { - LoggerService.info('Qlty not found. Attempting install...', { - category: STATIC_ANALYSIS_LOG_CATEGORY, - }); - - ToolInstaller.ensureDirectory(ToolInstaller.getBinDir()); - - const installCommand = process.platform === 'win32' - ? { - cmd: 'powershell', - args: [ - '-NoProfile', - '-ExecutionPolicy', - 'Bypass', - '-Command', - 'iwr -useb https://qlty.sh | iex', - ], - } - : { - cmd: 'sh', - args: ['-c', 'curl -fsSL https://qlty.sh | sh'], - }; - - const installResult = ToolInstaller.runCommand(installCommand.cmd, installCommand.args); - if (installResult.status !== 0 || installResult.error) { - return { - success: false, - executable: null, - error: ToolInstaller.formatCommandError(installCommand.cmd, installCommand.args, installResult), - }; - } - - const discoveredExecutable = ToolInstaller.findExecutable('qlty') - || ToolInstaller.findExecutableAt(path.join(os.homedir(), '.qlty', 'bin', ToolInstaller.binaryName('qlty'))) - || ToolInstaller.findExecutableAt(path.join(os.homedir(), '.local', 'bin', ToolInstaller.binaryName('qlty'))); - - if (!discoveredExecutable) { - return { - success: false, - executable: null, - error: 'Qlty installer completed, but executable was not found in PATH or known install locations.', - }; - } - - const managedExecutable = ToolInstaller.getManagedExecutablePath('qlty'); - ToolInstaller.linkOrCopyExecutable(discoveredExecutable, managedExecutable); - if (ToolInstaller.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 installQltyWorkflow({ + runCommand: (command, args) => ToolInstaller.runCommand(command, args), + isToolInstalled: (executablePath) => ToolInstaller.isToolInstalled(executablePath), + findExecutable: (toolName) => ToolInstaller.findExecutable(toolName), }); - return { success: true, executable: discoveredExecutable }; } static async ensureAvailable(autoInstall: boolean): Promise { - const cacheKey = ToolInstaller.getAvailabilityCacheKey(autoInstall); - - const cachedAvailability = ToolInstaller.cachedAvailabilityByMode.get(cacheKey); - if (cachedAvailability) { - return cachedAvailability; - } - - const inFlightAvailability = ToolInstaller.inFlightAvailabilityByMode.get(cacheKey); - if (inFlightAvailability) { - return inFlightAvailability; - } - - const availabilityPromise = Promise.resolve(ToolInstaller.computeAvailability(autoInstall)) - .then((availability) => { - ToolInstaller.cachedAvailabilityByMode.set(cacheKey, availability); - return availability; - }) - .finally(() => { - ToolInstaller.inFlightAvailabilityByMode.delete(cacheKey); - }); - - ToolInstaller.inFlightAvailabilityByMode.set(cacheKey, availabilityPromise); - return availabilityPromise; - } - - private static getAvailabilityCacheKey(autoInstall: boolean): string { - return autoInstall - ? ToolInstaller.AUTO_INSTALL_CACHE_KEY - : ToolInstaller.NO_AUTO_INSTALL_CACHE_KEY; + return ToolInstallerCache.getOrCompute(autoInstall, () => + ToolInstaller.computeAvailability(autoInstall) + ); } private static computeAvailability(autoInstall: boolean): ToolAvailability { @@ -271,9 +98,8 @@ export class ToolInstaller { }; } - const installResult = toolName === 'semgrep' - ? ToolInstaller.installSemgrep() - : ToolInstaller.installQlty(); + const installResult = + toolName === 'semgrep' ? ToolInstaller.installSemgrep() : ToolInstaller.installQlty(); if (installResult.success && installResult.executable) { return { @@ -291,144 +117,10 @@ export class ToolInstaller { }; } - private static installSemgrepWithUserPip(): 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 = ToolInstaller.runCommand(fallback.cmd, fallback.args); - if (result.status === 0 && !result.error) { - const detected = ToolInstaller.findExecutable('semgrep'); - if (detected) { - LoggerService.info(`Semgrep installed via fallback at ${detected}`, { - category: STATIC_ANALYSIS_LOG_CATEGORY, - }); - return { success: true, executable: detected }; - } - } - lastError = ToolInstaller.formatCommandError(fallback.cmd, fallback.args, result); - } - - return { - success: false, - executable: null, - error: lastError || 'Fallback semgrep install failed', - }; - } - - private static findInSystemPath(toolName: string): string | null { - const lookupCommand = process.platform === 'win32' ? 'where' : 'which'; - const result = ToolInstaller.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 (ToolInstaller.findExecutableAt(candidate)) { - return candidate; - } - } - - return null; - } - - private static runCommand(command: string, args: string[]): childProcess.SpawnSyncReturns { - return ToolInstaller.commandRunner(command, args); - } - - private static extractVersion(stdout?: string, stderr?: string): string | undefined { - const combined = `${stdout || ''}\n${stderr || ''}` - .split(/\r?\n/) - .map((line) => line.trim()) - .find(Boolean); - return combined || undefined; - } - - private static formatCommandError( + private static runCommand( 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'}`; - } - - private static ensureDirectory(targetDir: string): void { - fs.mkdirSync(targetDir, { recursive: true }); - } - - private static fileExists(targetPath: string): boolean { - try { - return fs.existsSync(targetPath); - } catch { - return false; - } - } - - private static findExecutableAt(targetPath: string): string | null { - return ToolInstaller.fileExists(targetPath) ? targetPath : null; - } - - private static linkOrCopyExecutable(sourcePath: string, targetPath: string): void { - ToolInstaller.ensureDirectory(path.dirname(targetPath)); - if (ToolInstaller.fileExists(targetPath)) { - fs.rmSync(targetPath, { force: true }); - } - - try { - fs.symlinkSync(sourcePath, targetPath); - } catch { - fs.copyFileSync(sourcePath, targetPath); - fs.chmodSync(targetPath, 0o755); - } - } - - private 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'); - } - - private static getBinDir(): string { - return path.join(ToolInstaller.getToolsDir(), 'bin'); - } - - private static getVenvDir(): string { - return path.join(ToolInstaller.getToolsDir(), 'semgrep-venv'); - } - - private static getManagedExecutablePath(toolName: string): string { - return path.join(ToolInstaller.getBinDir(), ToolInstaller.binaryName(toolName)); - } - - private static binaryName(toolName: string): string { - return process.platform === 'win32' ? `${toolName}.exe` : toolName; + 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; + } +} From b581c4f6657e2407e8230ae7beae40fe4b33e0b4 Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Mon, 16 Feb 2026 15:31:41 +0100 Subject: [PATCH 08/24] refactor(static-analysis): introduce runner registry for orchestration --- .../__tests__/static-analysis.service.test.ts | 98 ++++++++++++++ .../__tests__/tool-runner-registry.test.ts | 121 ++++++++++++++++++ .../static-analysis.service.ts | 36 +++--- .../static-analysis/tool-runner-registry.ts | 69 ++++++++++ 4 files changed, 306 insertions(+), 18 deletions(-) create mode 100644 src/services/static-analysis/__tests__/tool-runner-registry.test.ts create mode 100644 src/services/static-analysis/tool-runner-registry.ts diff --git a/src/services/static-analysis/__tests__/static-analysis.service.test.ts b/src/services/static-analysis/__tests__/static-analysis.service.test.ts index a12c59b..86b1aff 100644 --- a/src/services/static-analysis/__tests__/static-analysis.service.test.ts +++ b/src/services/static-analysis/__tests__/static-analysis.service.test.ts @@ -268,6 +268,104 @@ describe('StaticAnalysisService', () => { expect(result.summary.truncated).toBe(true); }); + it('shows qlty warning only when qlty is 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.mockResolvedValue({ + stdout: JSON.stringify({ results: [] }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const result = await StaticAnalysisService.analyze( + ['src/app.ts'], + repoRoot, + createConfig({ + qlty: { + enabled: true, + timeout: 30_000, + }, + }) + ); + + expect(result.summary.available.qlty).toBe(false); + expect(result.summary.warnings).toContain('Qlty runner is not implemented yet; qlty scan skipped.'); + }); + + it('does not show qlty warning 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, + }, + }); + mockedSpawnAsync.mockResolvedValue({ + stdout: JSON.stringify({ results: [] }), + stderr: '', + exitCode: 0, + timedOut: false, + }); + + const result = await StaticAnalysisService.analyze( + ['src/app.ts'], + repoRoot, + createConfig({ + qlty: { + enabled: true, + timeout: 30_000, + }, + }) + ); + + expect( + result.summary.warnings.some( + (warning) => warning === 'Qlty runner is not implemented yet; qlty scan skipped.' + ) + ).toBe(false); + }); + + it('keeps semgrep unavailable warning text unchanged', async () => { + vi.mocked(ToolInstaller.ensureAvailable).mockResolvedValue({ + semgrep: { + available: false, + executable: null, + installedNow: false, + }, + 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 is not available; semgrep scan skipped.'); + }); + it('returns immediately when static analysis is disabled', async () => { const availabilitySpy = vi.spyOn(ToolInstaller, 'ensureAvailable'); const result = await StaticAnalysisService.analyze( 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..a8b9ee1 --- /dev/null +++ b/src/services/static-analysis/__tests__/tool-runner-registry.test.ts @@ -0,0 +1,121 @@ +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, + excludedPaths: [], + semgrep: { + enabled: true, + rules: ['auto'], + timeout: 30_000, + }, + qlty: { + enabled: false, + timeout: 30_000, + }, + 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.source).toBe('semgrep'); + expect(semgrepPlan.runnable).toBe(true); + expect(semgrepPlan.warnings).toEqual([]); + expect(semgrepPlan.createRunner).not.toBeNull(); + expect(semgrepPlan.createRunner?.().name).toBe('semgrep'); + }); + + it('returns unchanged semgrep unavailable warning when semgrep is enabled but missing', () => { + const plans = buildToolRunnerPlans( + createConfig(), + createAvailability({ + semgrep: { + available: false, + executable: null, + installedNow: false, + }, + }) + ); + const semgrepPlan = plans[0]; + + expect(semgrepPlan.runnable).toBe(false); + expect(semgrepPlan.createRunner).toBeNull(); + expect(semgrepPlan.warnings).toEqual(['Semgrep is not available; semgrep scan skipped.']); + }); + + it('keeps qlty non-runnable and warns only when qlty is enabled and available', () => { + const plans = buildToolRunnerPlans( + createConfig({ + qlty: { + enabled: true, + timeout: 15_000, + }, + }), + createAvailability({ + qlty: { + available: true, + executable: '/usr/bin/qlty', + installedNow: false, + }, + }) + ); + const qltyPlan = plans[1]; + + expect(qltyPlan.source).toBe('qlty'); + expect(qltyPlan.runnable).toBe(false); + expect(qltyPlan.createRunner).toBeNull(); + expect(qltyPlan.warnings).toEqual(['Qlty runner is not implemented yet; qlty scan skipped.']); + }); + + it('does not warn about qlty when qlty is enabled but unavailable', () => { + const plans = buildToolRunnerPlans( + createConfig({ + qlty: { + enabled: true, + timeout: 15_000, + }, + }), + createAvailability({ + qlty: { + available: false, + executable: null, + installedNow: false, + }, + }) + ); + const qltyPlan = plans[1]; + + expect(qltyPlan.runnable).toBe(false); + expect(qltyPlan.warnings).toEqual([]); + }); + + it('keeps plan order semgrep then qlty', () => { + const plans = buildToolRunnerPlans(createConfig(), createAvailability()); + expect(plans.map((plan) => plan.source)).toEqual(['semgrep', 'qlty']); + }); +}); diff --git a/src/services/static-analysis/static-analysis.service.ts b/src/services/static-analysis/static-analysis.service.ts index 95e35cb..f427788 100644 --- a/src/services/static-analysis/static-analysis.service.ts +++ b/src/services/static-analysis/static-analysis.service.ts @@ -1,7 +1,7 @@ import { LoggerService } from '../../common/logger'; import { FileScopeResolver } from './file-scope-resolver'; -import { SemgrepRunner } from './semgrep-runner'; import { ToolInstaller } from './tool-installer'; +import { buildToolRunnerPlans } from './tool-runner-registry'; import { FindingSeverity, IToolRunner, @@ -76,23 +76,17 @@ export class StaticAnalysisService { toolAvailabilityMs = Date.now() - availabilityStart; } - const semgrepRunnable = Boolean( - config.semgrep.enabled && availability.semgrep.available && availability.semgrep.executable - ); - // Qlty runner is intentionally not wired yet in this phase. - const qltyRunnable = false; + const toolRunnerPlans = buildToolRunnerPlans(config, availability); + const semgrepPlan = toolRunnerPlans.find((plan) => plan.source === 'semgrep'); + const qltyPlan = toolRunnerPlans.find((plan) => plan.source === 'qlty'); + effectiveAvailability = { - semgrep: semgrepRunnable, - qlty: qltyRunnable, + semgrep: semgrepPlan?.runnable ?? false, + qlty: qltyPlan?.runnable ?? false, }; - if (config.semgrep.enabled && !availability.semgrep.available) { - warnings.push( - availability.semgrep.error || 'Semgrep is not available; semgrep scan skipped.' - ); - } - if (config.qlty.enabled && availability.qlty.available) { - warnings.push('Qlty runner is not implemented yet; qlty scan skipped.'); + for (const plan of toolRunnerPlans) { + warnings.push(...plan.warnings); } const scopeStart = Date.now(); @@ -121,8 +115,12 @@ export class StaticAnalysisService { } const runners: IToolRunner[] = []; - if (semgrepRunnable && availability.semgrep.executable) { - runners.push(new SemgrepRunner(availability.semgrep.executable, config.semgrep.rules)); + const toolPlanBySource = new Map(toolRunnerPlans.map((plan) => [plan.source, plan])); + + for (const plan of toolRunnerPlans) { + if (plan.runnable && plan.createRunner) { + runners.push(plan.createRunner()); + } } if (runners.length === 0) { @@ -150,7 +148,9 @@ export class StaticAnalysisService { for (const runner of runners) { const toolStart = Date.now(); try { - const timeout = runner.name === 'semgrep' ? config.semgrep.timeout : config.qlty.timeout; + const timeout = + toolPlanBySource.get(runner.name)?.timeoutMs ?? + (runner.name === 'semgrep' ? config.semgrep.timeout : config.qlty.timeout); const result = await runner.scan(scope.scanFiles, repoPath, timeout); allFindings.push(...result.findings); 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..14fc6e4 --- /dev/null +++ b/src/services/static-analysis/tool-runner-registry.ts @@ -0,0 +1,69 @@ +import { SemgrepRunner } from './semgrep-runner'; +import { + FindingSource, + IToolRunner, + StaticAnalysisConfig, + ToolAvailability, +} from './types'; + +export interface ToolRunnerPlan { + 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 qltyPlan = buildQltyPlan(config, availability); + return [semgrepPlan, qltyPlan]; +} + +function buildSemgrepPlan( + config: StaticAnalysisConfig, + availability: ToolAvailability +): ToolRunnerPlan { + const runnable = Boolean( + config.semgrep.enabled && availability.semgrep.available && availability.semgrep.executable + ); + const warnings: string[] = []; + + if (config.semgrep.enabled && !availability.semgrep.available) { + warnings.push(availability.semgrep.error || 'Semgrep is not available; semgrep scan skipped.'); + } + + return { + source: 'semgrep', + runnable, + timeoutMs: config.semgrep.timeout, + warnings, + createRunner: + runnable && availability.semgrep.executable + ? () => new SemgrepRunner(availability.semgrep.executable!, config.semgrep.rules) + : null, + }; +} + +function buildQltyPlan( + config: StaticAnalysisConfig, + availability: ToolAvailability +): ToolRunnerPlan { + const warnings: string[] = []; + + // Qlty runner is intentionally not wired yet in this phase. + if (config.qlty.enabled && availability.qlty.available) { + warnings.push('Qlty runner is not implemented yet; qlty scan skipped.'); + } + + return { + source: 'qlty', + runnable: false, + timeoutMs: config.qlty.timeout, + warnings, + createRunner: null, + }; +} From c28fd9f9bbc6e6b8be1f68088cc4eaba60112f9f Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Tue, 17 Feb 2026 11:50:17 +0100 Subject: [PATCH 09/24] feat(static-analysis): add qlty runner with sarif parsing and init retry --- .../__tests__/qlty-runner.test.ts | 354 ++++++++++++++ src/services/static-analysis/qlty-runner.ts | 458 ++++++++++++++++++ 2 files changed, 812 insertions(+) create mode 100644 src/services/static-analysis/__tests__/qlty-runner.test.ts create mode 100644 src/services/static-analysis/qlty-runner.ts 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..42be339 --- /dev/null +++ b/src/services/static-analysis/__tests__/qlty-runner.test.ts @@ -0,0 +1,354 @@ +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); + +describe('QltyRunner', () => { + beforeEach(() => { + mockedSpawnAsync.mockReset(); + LoggerService.setTransport(new SilentTransport()); + LoggerService.setLevel('debug'); + }); + + afterEach(() => { + 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', + 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('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); + }); +}); diff --git a/src/services/static-analysis/qlty-runner.ts b/src/services/static-analysis/qlty-runner.ts new file mode 100644 index 0000000..6d30f75 --- /dev/null +++ b/src/services/static-analysis/qlty-runner.ts @@ -0,0 +1,458 @@ +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 SarifReport = { + runs?: SarifRun[]; +}; + +type SarifRun = { + results?: SarifResult[]; + tool?: { + driver?: { + rules?: SarifRule[]; + }; + }; +}; + +type SarifRule = { + id?: unknown; + properties?: { + tags?: unknown; + category?: 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; +}; + +export class QltyRunner implements IToolRunner { + readonly name: FindingSource = 'qlty'; + + 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 (this.requiresInit(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 (initOutput.exitCode === 0) { + 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 = this.parseSarif(rawOutput.stdout); + if (findings.length === 0 && rawOutput.stderr.trim()) { + findings = this.parseSarif(rawOutput.stderr); + } + if (findings.length === 0) { + findings = this.parseText(`${rawOutput.stdout}\n${rawOutput.stderr}`); + } + + 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 requiresInit(rawOutput: SpawnResult): boolean { + const combined = `${rawOutput.stdout}\n${rawOutput.stderr}`.toLowerCase(); + return ( + combined.includes('qlty init') && + (combined.includes('not initialized') || + combined.includes('initialize') || + combined.includes('must run')) + ); + } + + private parseSarif(output: string): 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 = this.buildRuleIndex(run); + const runResults = Array.isArray(run.results) ? run.results : []; + for (const result of runResults) { + const mapped = this.mapSarifResultToFinding(result, rulesById); + 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 []; + } + } + + private parseText(output: string): 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 = this.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 = this.mapSeverity(severityMatch[1]); + message = (severityMatch[2] || '').trim() || message; + } + + let category: FindingCategory | null = null; + + const bracketCategoryMatch = /^\[([^\]]+)\]\s*(.*)$/.exec(message); + if (bracketCategoryMatch) { + category = this.normalizeCategoryToken(bracketCategoryMatch[1]); + message = (bracketCategoryMatch[2] || '').trim() || message; + } else { + const prefixCategoryMatch = /^([A-Za-z][A-Za-z0-9_-]{2,})\b[:\s-]+(.*)$/.exec(message); + if (prefixCategoryMatch) { + category = this.normalizeCategoryToken(prefixCategoryMatch[1]); + if (category) { + message = (prefixCategoryMatch[2] || '').trim() || message; + } + } + } + + const derivedCategory = category || this.deriveCategory('unknown.qlty.rule', message, []); + findings.push({ + source: 'qlty', + 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; + } + + private 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 = this.readNonEmptyString(rule.id); + if (id) { + index.set(id, rule); + } + } + return index; + } + + private mapSarifResultToFinding( + result: SarifResult, + rulesById: Map + ): StaticAnalysisFinding | null { + if (!result || typeof result !== 'object') { + return null; + } + + const ruleId = this.readNonEmptyString(result.ruleId) || 'unknown.qlty.rule'; + const linkedRule = rulesById.get(ruleId); + + const firstLocation = Array.isArray(result.locations) ? result.locations[0] : undefined; + const file = this.normalizeFilePath( + this.readNonEmptyString(firstLocation?.physicalLocation?.artifactLocation?.uri) || 'unknown.file' + ); + const line = this.readPositiveInteger(firstLocation?.physicalLocation?.region?.startLine, 1); + const column = this.readPositiveInteger(firstLocation?.physicalLocation?.region?.startColumn, 1); + + const message = this.extractMessage(result.message) || 'Qlty finding'; + const severity = this.mapSeverity(this.readNonEmptyString(result.level) || ''); + + const categoryTokens: string[] = []; + const resultCategory = this.readNonEmptyString(result.properties?.category); + if (resultCategory) { + categoryTokens.push(resultCategory); + } + + categoryTokens.push(...this.readStringArray(result.properties?.tags)); + categoryTokens.push(...this.readStringArray(linkedRule?.properties?.tags)); + + const linkedRuleCategory = this.readNonEmptyString(linkedRule?.properties?.category); + if (linkedRuleCategory) { + categoryTokens.push(linkedRuleCategory); + } + + const category = this.deriveCategory(ruleId, message, categoryTokens); + const fixable = + (Array.isArray(result.fixes) && result.fixes.length > 0) || result.properties?.fixable === true; + + return { + source: 'qlty', + file: file || 'unknown.file', + line, + column, + message, + severity, + category, + ruleId, + fixable: fixable ? true : undefined, + }; + } + + private extractMessage(message: unknown): string | null { + if (typeof message === 'string' && message.trim()) { + return message.trim(); + } + if (!message || typeof message !== 'object') { + return null; + } + const messageText = this.readNonEmptyString((message as { text?: unknown }).text); + if (messageText) { + return messageText; + } + return this.readNonEmptyString((message as { markdown?: unknown }).markdown); + } + + private mapSeverity(level: string): FindingSeverity { + const normalized = level.trim().toLowerCase(); + if (normalized === 'error') { + return 'error'; + } + if (normalized === 'warning') { + return 'warning'; + } + return 'info'; + } + + private deriveCategory( + ruleId: string, + message: string, + categoryTokens: string[] + ): FindingCategory { + for (const token of categoryTokens) { + const normalized = this.normalizeCategoryToken(token); + if (normalized) { + return normalized; + } + } + + const haystack = `${ruleId} ${message}`.toLowerCase(); + const inferred = this.normalizeCategoryToken(haystack); + return inferred || 'quality'; + } + + private normalizeCategoryToken(token: string): FindingCategory | null { + const value = token.trim().toLowerCase(); + if (!value) { + return null; + } + + if (value.includes('security')) { + return 'security'; + } + if ( + value.includes('maintainability') || + value.includes('quality') || + value.includes('performance') + ) { + return 'quality'; + } + if (value.includes('duplication')) { + return 'duplication'; + } + if (value.includes('complexity')) { + return 'complexity'; + } + if (value.includes('style') || value.includes('format')) { + return 'style'; + } + if (value.includes('bug') || value.includes('correctness')) { + return 'bug'; + } + + return null; + } + + private 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('/'); + } + + private readNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null; + } + + 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); + } + + private readPositiveInteger(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback; + } +} From 36b4a7e2ba9cad4ac7484758aba419e3a6daade4 Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Tue, 17 Feb 2026 11:50:37 +0100 Subject: [PATCH 10/24] feat(static-analysis): wire qlty runner through registry and service tests --- .../__tests__/static-analysis.service.test.ts | 60 +++++++++++++++---- .../__tests__/tool-runner-registry.test.ts | 33 ++++++++-- src/services/static-analysis/index.ts | 1 + .../static-analysis/tool-runner-registry.ts | 16 +++-- 4 files changed, 91 insertions(+), 19 deletions(-) diff --git a/src/services/static-analysis/__tests__/static-analysis.service.test.ts b/src/services/static-analysis/__tests__/static-analysis.service.test.ts index 86b1aff..e420934 100644 --- a/src/services/static-analysis/__tests__/static-analysis.service.test.ts +++ b/src/services/static-analysis/__tests__/static-analysis.service.test.ts @@ -268,7 +268,7 @@ describe('StaticAnalysisService', () => { expect(result.summary.truncated).toBe(true); }); - it('shows qlty warning only when qlty is enabled and available', async () => { + it('runs qlty when enabled and available (with semgrep disabled in this test)', async () => { vi.mocked(ToolInstaller.ensureAvailable).mockResolvedValue({ semgrep: { available: true, @@ -282,7 +282,27 @@ describe('StaticAnalysisService', () => { }, }); mockedSpawnAsync.mockResolvedValue({ - stdout: JSON.stringify({ results: [] }), + 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, @@ -292,6 +312,11 @@ describe('StaticAnalysisService', () => { ['src/app.ts'], repoRoot, createConfig({ + semgrep: { + enabled: false, + rules: ['auto'], + timeout: 30_000, + }, qlty: { enabled: true, timeout: 30_000, @@ -299,11 +324,23 @@ describe('StaticAnalysisService', () => { }) ); - expect(result.summary.available.qlty).toBe(false); - expect(result.summary.warnings).toContain('Qlty runner is not implemented yet; qlty scan skipped.'); + 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', + category: 'complexity', + severity: 'warning', + }); + expect(result.rawOutputs.qlty?.exitCode).toBe(0); }); - it('does not show qlty warning when qlty is enabled but unavailable', async () => { + it('does not run qlty when qlty is enabled but unavailable', async () => { vi.mocked(ToolInstaller.ensureAvailable).mockResolvedValue({ semgrep: { available: true, @@ -327,6 +364,11 @@ describe('StaticAnalysisService', () => { ['src/app.ts'], repoRoot, createConfig({ + semgrep: { + enabled: false, + rules: ['auto'], + timeout: 30_000, + }, qlty: { enabled: true, timeout: 30_000, @@ -334,11 +376,9 @@ describe('StaticAnalysisService', () => { }) ); - expect( - result.summary.warnings.some( - (warning) => warning === 'Qlty runner is not implemented yet; qlty scan skipped.' - ) - ).toBe(false); + 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('keeps semgrep unavailable warning text unchanged', async () => { diff --git a/src/services/static-analysis/__tests__/tool-runner-registry.test.ts b/src/services/static-analysis/__tests__/tool-runner-registry.test.ts index a8b9ee1..f298f2e 100644 --- a/src/services/static-analysis/__tests__/tool-runner-registry.test.ts +++ b/src/services/static-analysis/__tests__/tool-runner-registry.test.ts @@ -68,7 +68,7 @@ describe('buildToolRunnerPlans', () => { expect(semgrepPlan.warnings).toEqual(['Semgrep is not available; semgrep scan skipped.']); }); - it('keeps qlty non-runnable and warns only when qlty is enabled and available', () => { + it('marks qlty runnable when qlty is enabled and available', () => { const plans = buildToolRunnerPlans( createConfig({ qlty: { @@ -87,9 +87,10 @@ describe('buildToolRunnerPlans', () => { const qltyPlan = plans[1]; expect(qltyPlan.source).toBe('qlty'); - expect(qltyPlan.runnable).toBe(false); - expect(qltyPlan.createRunner).toBeNull(); - expect(qltyPlan.warnings).toEqual(['Qlty runner is not implemented yet; qlty scan skipped.']); + expect(qltyPlan.runnable).toBe(true); + expect(qltyPlan.warnings).toEqual([]); + expect(qltyPlan.createRunner).not.toBeNull(); + expect(qltyPlan.createRunner?.().name).toBe('qlty'); }); it('does not warn about qlty when qlty is enabled but unavailable', () => { @@ -114,6 +115,30 @@ describe('buildToolRunnerPlans', () => { expect(qltyPlan.warnings).toEqual([]); }); + it('surfaces qlty availability error when qlty is enabled but unavailable with explicit error', () => { + const plans = buildToolRunnerPlans( + createConfig({ + qlty: { + enabled: true, + timeout: 15_000, + }, + }), + createAvailability({ + qlty: { + available: false, + executable: null, + installedNow: false, + error: 'qlty failed to install', + }, + }) + ); + const qltyPlan = plans[1]; + + expect(qltyPlan.runnable).toBe(false); + expect(qltyPlan.createRunner).toBeNull(); + expect(qltyPlan.warnings).toEqual(['qlty failed to install']); + }); + it('keeps plan order semgrep then qlty', () => { const plans = buildToolRunnerPlans(createConfig(), createAvailability()); expect(plans.map((plan) => plan.source)).toEqual(['semgrep', 'qlty']); diff --git a/src/services/static-analysis/index.ts b/src/services/static-analysis/index.ts index e27545c..e8da67d 100644 --- a/src/services/static-analysis/index.ts +++ b/src/services/static-analysis/index.ts @@ -3,4 +3,5 @@ export * from './spawn-helper'; export * from './file-scope-resolver'; export * from './tool-installer'; export * from './semgrep-runner'; +export * from './qlty-runner'; export * from './static-analysis.service'; diff --git a/src/services/static-analysis/tool-runner-registry.ts b/src/services/static-analysis/tool-runner-registry.ts index 14fc6e4..318f248 100644 --- a/src/services/static-analysis/tool-runner-registry.ts +++ b/src/services/static-analysis/tool-runner-registry.ts @@ -1,4 +1,5 @@ import { SemgrepRunner } from './semgrep-runner'; +import { QltyRunner } from './qlty-runner'; import { FindingSource, IToolRunner, @@ -52,18 +53,23 @@ function buildQltyPlan( config: StaticAnalysisConfig, availability: ToolAvailability ): ToolRunnerPlan { + const runnable = Boolean( + config.qlty.enabled && availability.qlty.available && availability.qlty.executable + ); const warnings: string[] = []; - // Qlty runner is intentionally not wired yet in this phase. - if (config.qlty.enabled && availability.qlty.available) { - warnings.push('Qlty runner is not implemented yet; qlty scan skipped.'); + if (config.qlty.enabled && !availability.qlty.available && availability.qlty.error) { + warnings.push(availability.qlty.error); } return { source: 'qlty', - runnable: false, + runnable, timeoutMs: config.qlty.timeout, warnings, - createRunner: null, + createRunner: + runnable && availability.qlty.executable + ? () => new QltyRunner(availability.qlty.executable!) + : null, }; } From ad54ac1c8fcd363418814fb68bb9fc9334d8daef Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Wed, 18 Feb 2026 14:37:43 +0100 Subject: [PATCH 11/24] fix(static-analysis): harden qlty init/bootstrap recovery --- .../__tests__/qlty-runner.test.ts | 213 ++++++++ .../__tests__/qlty-smells-runner.test.ts | 265 ++++++++++ src/services/static-analysis/qlty-runner.ts | 456 +++++------------- .../static-analysis/qlty-smells-runner.ts | 164 +++++++ 4 files changed, 776 insertions(+), 322 deletions(-) create mode 100644 src/services/static-analysis/__tests__/qlty-smells-runner.test.ts create mode 100644 src/services/static-analysis/qlty-smells-runner.ts diff --git a/src/services/static-analysis/__tests__/qlty-runner.test.ts b/src/services/static-analysis/__tests__/qlty-runner.test.ts index 42be339..fd7b2ab 100644 --- a/src/services/static-analysis/__tests__/qlty-runner.test.ts +++ b/src/services/static-analysis/__tests__/qlty-runner.test.ts @@ -1,3 +1,4 @@ +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'; @@ -9,15 +10,18 @@ vi.mock('../spawn-helper', () => ({ })); 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(); }); @@ -71,6 +75,7 @@ describe('QltyRunner', () => { expect(result.findings).toEqual([ { source: 'qlty', + runnerId: 'qlty-check', file: 'src/db.ts', line: 12, column: 7, @@ -227,6 +232,74 @@ describe('QltyRunner', () => { 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({ @@ -351,4 +424,144 @@ describe('QltyRunner', () => { 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/qlty-runner.ts b/src/services/static-analysis/qlty-runner.ts index 6d30f75..09f53c6 100644 --- a/src/services/static-analysis/qlty-runner.ts +++ b/src/services/static-analysis/qlty-runner.ts @@ -1,64 +1,15 @@ +import fs from 'fs'; +import path from 'path'; import { LoggerService } from '../../common/logger'; import { SpawnResult, spawnAsync } from './spawn-helper'; -import { - FindingCategory, - FindingSeverity, - FindingSource, - IToolRunner, - StaticAnalysisFinding, -} from './types'; +import { parseQltyOutput } from './qlty-parser'; +import { FindingSource, IToolRunner, StaticAnalysisFinding } from './types'; const STATIC_ANALYSIS_LOG_CATEGORY = 'static-analysis'; -type SarifReport = { - runs?: SarifRun[]; -}; - -type SarifRun = { - results?: SarifResult[]; - tool?: { - driver?: { - rules?: SarifRule[]; - }; - }; -}; - -type SarifRule = { - id?: unknown; - properties?: { - tags?: unknown; - category?: 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; -}; - export class QltyRunner implements IToolRunner { readonly name: FindingSource = 'qlty'; + private static readonly bootstrappedCheckRepos = new Set(); constructor(private readonly executablePath: string) {} @@ -120,7 +71,7 @@ export class QltyRunner implements IToolRunner { return { findings: [], rawOutput }; } - if (initOutput.exitCode === 0) { + if (this.initCompleted(repoPath, initOutput)) { rawOutput = await spawnAsync(this.executablePath, checkArgs, { cwd: repoPath, timeout, @@ -142,12 +93,35 @@ export class QltyRunner implements IToolRunner { } } - let findings = this.parseSarif(rawOutput.stdout); - if (findings.length === 0 && rawOutput.stderr.trim()) { - findings = this.parseSarif(rawOutput.stderr); - } - if (findings.length === 0) { - findings = this.parseText(`${rawOutput.stdout}\n${rawOutput.stderr}`); + 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)`, { @@ -164,295 +138,133 @@ export class QltyRunner implements IToolRunner { return ['check', '--sarif', '--no-progress', ...files]; } - private requiresInit(rawOutput: SpawnResult): boolean { - const combined = `${rawOutput.stdout}\n${rawOutput.stderr}`.toLowerCase(); - return ( - combined.includes('qlty init') && - (combined.includes('not initialized') || - combined.includes('initialize') || - combined.includes('must run')) - ); - } - - private parseSarif(output: string): StaticAnalysisFinding[] { - if (!output || !output.trim()) { - return []; + 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); + } - try { - const parsed = JSON.parse(output) as SarifReport; - if (!parsed || !Array.isArray(parsed.runs)) { - return []; - } + private async bootstrapChecks(repoPath: string, timeout: number): Promise { + let installOutput = await spawnAsync(this.executablePath, ['install'], { + cwd: repoPath, + timeout, + }); - const findings: StaticAnalysisFinding[] = []; - for (const run of parsed.runs) { - const rulesById = this.buildRuleIndex(run); - const runResults = Array.isArray(run.results) ? run.results : []; - for (const result of runResults) { - const mapped = this.mapSarifResultToFinding(result, rulesById); - if (mapped) { - findings.push(mapped); - } - } - } - return findings; - } catch (error) { - LoggerService.debug('Qlty SARIF parse failed; trying text fallback', { + if (installOutput.timedOut) { + LoggerService.warn(`Qlty install timed out after ${timeout}ms; skipping check bootstrap`, { category: STATIC_ANALYSIS_LOG_CATEGORY, - data: { - error: error instanceof Error ? error.message : String(error), - }, }); - return []; - } - } - - private parseText(output: string): StaticAnalysisFinding[] { - if (!output || !output.trim()) { - return []; + return false; } - 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 = this.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(); + if (this.requiresInit(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, + }); - let severity: FindingSeverity = 'warning'; - const severityMatch = /^(error|warning|info|note)\b[:\s-]*(.*)$/i.exec(message); - if (severityMatch) { - severity = this.mapSeverity(severityMatch[1]); - message = (severityMatch[2] || '').trim() || message; + if (initOutput.timedOut) { + LoggerService.warn(`Qlty init timed out after ${timeout}ms during bootstrap`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return false; } - let category: FindingCategory | null = null; - - const bracketCategoryMatch = /^\[([^\]]+)\]\s*(.*)$/.exec(message); - if (bracketCategoryMatch) { - category = this.normalizeCategoryToken(bracketCategoryMatch[1]); - message = (bracketCategoryMatch[2] || '').trim() || message; - } else { - const prefixCategoryMatch = /^([A-Za-z][A-Za-z0-9_-]{2,})\b[:\s-]+(.*)$/.exec(message); - if (prefixCategoryMatch) { - category = this.normalizeCategoryToken(prefixCategoryMatch[1]); - if (category) { - message = (prefixCategoryMatch[2] || '').trim() || message; - } - } + if (!this.initCompleted(repoPath, initOutput)) { + LoggerService.warn('Qlty init failed during check bootstrap; skipping retry.', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + data: { + exitCode: initOutput.exitCode, + }, + }); + return false; } - const derivedCategory = category || this.deriveCategory('unknown.qlty.rule', message, []); - findings.push({ - source: 'qlty', - 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}`, + installOutput = await spawnAsync(this.executablePath, ['install'], { + cwd: repoPath, + timeout, }); - } - - return findings; - } - - private 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 = this.readNonEmptyString(rule.id); - if (id) { - index.set(id, rule); + if (installOutput.timedOut) { + LoggerService.warn(`Qlty install timed out after ${timeout}ms on retry`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + return false; } } - return index; - } - - private mapSarifResultToFinding( - result: SarifResult, - rulesById: Map - ): StaticAnalysisFinding | null { - if (!result || typeof result !== 'object') { - return null; - } - - const ruleId = this.readNonEmptyString(result.ruleId) || 'unknown.qlty.rule'; - const linkedRule = rulesById.get(ruleId); - - const firstLocation = Array.isArray(result.locations) ? result.locations[0] : undefined; - const file = this.normalizeFilePath( - this.readNonEmptyString(firstLocation?.physicalLocation?.artifactLocation?.uri) || 'unknown.file' - ); - const line = this.readPositiveInteger(firstLocation?.physicalLocation?.region?.startLine, 1); - const column = this.readPositiveInteger(firstLocation?.physicalLocation?.region?.startColumn, 1); - - const message = this.extractMessage(result.message) || 'Qlty finding'; - const severity = this.mapSeverity(this.readNonEmptyString(result.level) || ''); - - const categoryTokens: string[] = []; - const resultCategory = this.readNonEmptyString(result.properties?.category); - if (resultCategory) { - categoryTokens.push(resultCategory); - } - - categoryTokens.push(...this.readStringArray(result.properties?.tags)); - categoryTokens.push(...this.readStringArray(linkedRule?.properties?.tags)); - const linkedRuleCategory = this.readNonEmptyString(linkedRule?.properties?.category); - if (linkedRuleCategory) { - categoryTokens.push(linkedRuleCategory); + 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; } - const category = this.deriveCategory(ruleId, message, categoryTokens); - const fixable = - (Array.isArray(result.fixes) && result.fixes.length > 0) || result.properties?.fixable === true; - - return { - source: 'qlty', - file: file || 'unknown.file', - line, - column, - message, - severity, - category, - ruleId, - fixable: fixable ? true : undefined, - }; - } - - private extractMessage(message: unknown): string | null { - if (typeof message === 'string' && message.trim()) { - return message.trim(); - } - if (!message || typeof message !== 'object') { - return null; - } - const messageText = this.readNonEmptyString((message as { text?: unknown }).text); - if (messageText) { - return messageText; - } - return this.readNonEmptyString((message as { markdown?: unknown }).markdown); + LoggerService.info('Qlty check bootstrap completed via `qlty install`.', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); + QltyRunner.bootstrappedCheckRepos.add(this.bootstrapKey(repoPath)); + return true; } - private mapSeverity(level: string): FindingSeverity { - const normalized = level.trim().toLowerCase(); - if (normalized === 'error') { - return 'error'; - } - if (normalized === 'warning') { - return 'warning'; - } - return 'info'; + private bootstrapKey(repoPath: string): string { + return `${this.executablePath}::${repoPath}`; } - private deriveCategory( - ruleId: string, - message: string, - categoryTokens: string[] - ): FindingCategory { - for (const token of categoryTokens) { - const normalized = this.normalizeCategoryToken(token); - if (normalized) { - return normalized; - } - } - - const haystack = `${ruleId} ${message}`.toLowerCase(); - const inferred = this.normalizeCategoryToken(haystack); - return inferred || 'quality'; - } - - private normalizeCategoryToken(token: string): FindingCategory | null { - const value = token.trim().toLowerCase(); - if (!value) { - return null; - } - - if (value.includes('security')) { - return 'security'; - } - if ( - value.includes('maintainability') || - value.includes('quality') || - value.includes('performance') - ) { - return 'quality'; - } - if (value.includes('duplication')) { - return 'duplication'; - } - if (value.includes('complexity')) { - return 'complexity'; - } - if (value.includes('style') || value.includes('format')) { - return 'style'; - } - if (value.includes('bug') || value.includes('correctness')) { - return 'bug'; - } + private requiresInit(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 null; + return ( + (combined.includes('qlty init') || combined.includes('try: qlty init')) && hasInitHint + ); } - private normalizeFilePath(fileUri: string): string { - const trimmed = fileUri.trim(); - if (!trimmed) { - return 'unknown.file'; + private initCompleted(repoPath: string, initOutput: SpawnResult): boolean { + if (initOutput.exitCode === 0) { + return true; } - 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('/'); - } - - private readNonEmptyString(value: unknown): string | null { - return typeof value === 'string' && value.trim() ? value.trim() : null; - } - - private readStringArray(value: unknown): string[] { - if (!Array.isArray(value)) { - return []; + 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 value - .filter((entry): entry is string => typeof entry === 'string') - .map((entry) => entry.trim()) - .filter(Boolean); - } - - private readPositiveInteger(value: unknown, fallback: number): number { - return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : fallback; + return false; } } 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..366b08a --- /dev/null +++ b/src/services/static-analysis/qlty-smells-runner.ts @@ -0,0 +1,164 @@ +import fs from 'fs'; +import path from 'path'; +import { LoggerService } from '../../common/logger'; +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 (this.requiresInit(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 (this.initCompleted(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; + } + + private requiresInit(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 + ); + } + + private initCompleted(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; + } +} From 1a3ae13f39fa0f8cac7c13ea691a85af85772585 Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Wed, 18 Feb 2026 14:49:46 +0100 Subject: [PATCH 12/24] feat(static-analysis): finalize qlty check/smells integration --- cli/utils/shared.utils.ts | 10 + .../config-loader.validation.test.ts | 24 ++ src/config/__tests__/config-validator.test.ts | 43 ++ src/config/config-loader.ts | 10 + src/config/config-validator.ts | 21 + src/config/default-config.ts | 6 + src/mcp-server/tools/handlers.ts | 5 +- .../__tests__/semgrep-runner.test.ts | 1 + .../__tests__/static-analysis.service.test.ts | 357 +++++++++------- .../__tests__/tool-runner-registry.test.ts | 106 +++-- src/services/static-analysis/index.ts | 1 + src/services/static-analysis/qlty-parser.ts | 388 ++++++++++++++++++ .../static-analysis/semgrep-runner.ts | 1 + .../static-analysis.service.ts | 83 ++-- .../static-analysis/tool-runner-registry.ts | 62 ++- src/services/static-analysis/types.ts | 17 + 16 files changed, 902 insertions(+), 233 deletions(-) create mode 100644 src/services/static-analysis/qlty-parser.ts diff --git a/cli/utils/shared.utils.ts b/cli/utils/shared.utils.ts index adc9198..363b6af 100644 --- a/cli/utils/shared.utils.ts +++ b/cli/utils/shared.utils.ts @@ -221,6 +221,7 @@ async function writeStaticAnalysisArtifacts( findings: string; rawSemgrep?: string; rawQlty?: string; + rawQltySmells?: string; }> { const staticDir = path.join(outputDir, 'static-analysis'); await fs.mkdir(staticDir, { recursive: true }); @@ -237,6 +238,7 @@ async function writeStaticAnalysisArtifacts( findings: string; rawSemgrep?: string; rawQlty?: string; + rawQltySmells?: string; } = { summary: 'static-analysis/summary.json', findings: 'static-analysis/findings.json', @@ -255,6 +257,14 @@ async function writeStaticAnalysisArtifacts( references.rawQlty = 'static-analysis/raw-qlty.json'; } + if (rawOutputs.qltySmells) { + await fs.writeFile( + path.join(staticDir, 'raw-qlty-smells.json'), + JSON.stringify(rawOutputs.qltySmells, null, 2) + ); + references.rawQltySmells = 'static-analysis/raw-qlty-smells.json'; + } + return references; } diff --git a/src/config/__tests__/config-loader.validation.test.ts b/src/config/__tests__/config-loader.validation.test.ts index 3976549..84a89c0 100644 --- a/src/config/__tests__/config-loader.validation.test.ts +++ b/src/config/__tests__/config-loader.validation.test.ts @@ -28,6 +28,11 @@ describe('loadConfig validation behavior', () => { semgrep: { timeout: 30000, }, + qlty: { + smells: { + includeDuplication: false, + }, + }, }, }) ); @@ -35,6 +40,25 @@ describe('loadConfig validation behavior', () => { const loaded = loadConfig(configPath); expect(loaded).not.toBeNull(); expect(loaded?.staticAnalysis?.semgrep.timeout).toBe(30000); + 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); + }); + + 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', () => { diff --git a/src/config/__tests__/config-validator.test.ts b/src/config/__tests__/config-validator.test.ts index f866b05..1ba4a3a 100644 --- a/src/config/__tests__/config-validator.test.ts +++ b/src/config/__tests__/config-validator.test.ts @@ -49,6 +49,7 @@ describe('validateConfig (staticAnalysis)', () => { 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); @@ -60,6 +61,48 @@ describe('validateConfig (staticAnalysis)', () => { 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', + }), ]) ); }); diff --git a/src/config/config-loader.ts b/src/config/config-loader.ts index 00d662e..2db3f10 100644 --- a/src/config/config-loader.ts +++ b/src/config/config-loader.ts @@ -28,6 +28,7 @@ export function loadConfig(configPath?: string): AppConfig | null { const staticAnalysisDefaults: NonNullable = DEFAULT_CONFIG.staticAnalysis ?? { enabled: true, autoInstall: true, + autoInstallSystemDeps: false, excludedPaths: [ '.git/**', 'node_modules/**', @@ -58,6 +59,11 @@ export function loadConfig(configPath?: string): AppConfig | null { qlty: { enabled: true, timeout: 60000, + smells: { + enabled: true, + timeout: 60000, + includeDuplication: true, + }, }, maxFindings: 500, minSeverity: 'warning', @@ -84,6 +90,10 @@ export function loadConfig(configPath?: string): AppConfig | null { qlty: { ...staticAnalysisDefaults.qlty, ...(userConfig.staticAnalysis.qlty || {}), + smells: { + ...staticAnalysisDefaults.qlty.smells, + ...(userConfig.staticAnalysis.qlty?.smells || {}), + }, }, } : staticAnalysisDefaults, diff --git a/src/config/config-validator.ts b/src/config/config-validator.ts index 3c37893..98780e9 100644 --- a/src/config/config-validator.ts +++ b/src/config/config-validator.ts @@ -30,6 +30,11 @@ export function validateConfig(config: AppConfig): void { 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`, @@ -79,6 +84,22 @@ export function validateConfig(config: AppConfig): void { 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, diff --git a/src/config/default-config.ts b/src/config/default-config.ts index e781747..7e8b3f9 100644 --- a/src/config/default-config.ts +++ b/src/config/default-config.ts @@ -55,6 +55,7 @@ export const DEFAULT_CONFIG: AppConfig = { staticAnalysis: { enabled: true, autoInstall: true, + autoInstallSystemDeps: false, excludedPaths: [ '.git/**', 'node_modules/**', @@ -85,6 +86,11 @@ export const DEFAULT_CONFIG: AppConfig = { qlty: { enabled: true, timeout: 60000, + smells: { + enabled: true, + timeout: 60000, + includeDuplication: true, + }, }, maxFindings: 500, minSeverity: 'warning', diff --git a/src/mcp-server/tools/handlers.ts b/src/mcp-server/tools/handlers.ts index 1e492c1..0d5e867 100644 --- a/src/mcp-server/tools/handlers.ts +++ b/src/mcp-server/tools/handlers.ts @@ -323,7 +323,10 @@ export function buildInlineConfig( ? { ...DEFAULT_CONFIG.staticAnalysis, semgrep: { ...DEFAULT_CONFIG.staticAnalysis.semgrep }, - qlty: { ...DEFAULT_CONFIG.staticAnalysis.qlty }, + qlty: { + ...DEFAULT_CONFIG.staticAnalysis.qlty, + smells: { ...DEFAULT_CONFIG.staticAnalysis.qlty.smells }, + }, excludedPaths: [...DEFAULT_CONFIG.staticAnalysis.excludedPaths], } : undefined, diff --git a/src/services/static-analysis/__tests__/semgrep-runner.test.ts b/src/services/static-analysis/__tests__/semgrep-runner.test.ts index 1192f01..062688c 100644 --- a/src/services/static-analysis/__tests__/semgrep-runner.test.ts +++ b/src/services/static-analysis/__tests__/semgrep-runner.test.ts @@ -68,6 +68,7 @@ describe('SemgrepRunner', () => { expect(result.findings).toHaveLength(1); expect(result.findings[0]).toEqual({ source: 'semgrep', + runnerId: 'semgrep', file: 'src/routes/user.ts', line: 45, column: 10, diff --git a/src/services/static-analysis/__tests__/static-analysis.service.test.ts b/src/services/static-analysis/__tests__/static-analysis.service.test.ts index e420934..2ef9aaf 100644 --- a/src/services/static-analysis/__tests__/static-analysis.service.test.ts +++ b/src/services/static-analysis/__tests__/static-analysis.service.test.ts @@ -19,6 +19,7 @@ function createConfig(overrides: Partial = {}): StaticAnal return { enabled: true, autoInstall: false, + autoInstallSystemDeps: false, excludedPaths: [], semgrep: { enabled: true, @@ -28,6 +29,11 @@ function createConfig(overrides: Partial = {}): StaticAnal qlty: { enabled: false, timeout: 30_000, + smells: { + enabled: true, + timeout: 30_000, + includeDuplication: true, + }, }, maxFindings: 500, minSeverity: 'warning', @@ -72,140 +78,32 @@ describe('StaticAnalysisService', () => { } }); - it('runs semgrep on resolved commit files and returns findings/raw output', async () => { - mockedSpawnAsync.mockResolvedValue({ - stdout: JSON.stringify({ - results: [ - { - check_id: 'security.xss.rule', - path: 'src/app.ts', - start: { line: 2, col: 4 }, - extra: { - message: 'Potential XSS', - severity: 'ERROR', - metadata: { category: 'security' }, - }, - }, - ], - }), - stderr: '', - exitCode: 1, - timedOut: false, - }); - + it('does not run semgrep during evaluation when qlty is disabled', async () => { const result = await StaticAnalysisService.analyze( ['src/app.ts'], repoRoot, createConfig() ); - 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.findings[0]).toMatchObject({ - source: 'semgrep', - file: 'src/app.ts', - severity: 'error', - ruleId: 'security.xss.rule', - }); - expect(result.rawOutputs.semgrep?.exitCode).toBe(1); - expect(result.summary.available.semgrep).toBe(true); + expect(mockedSpawnAsync).not.toHaveBeenCalled(); + expect(result.summary.counts.total).toBe(0); + expect(result.summary.available.semgrep).toBe(false); expect(result.summary.available.qlty).toBe(false); + expect(result.summary.warnings).toContain('No static analysis runners available to execute.'); 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('surfaces Semgrep engine warnings from top-level errors', async () => { - mockedSpawnAsync.mockResolvedValue({ - stdout: JSON.stringify({ - results: [], - errors: [ - { - rule_id: 'javascript.some.rule', - path: 'src/app.ts', - message: 'Internal matching error: engine limitation', - }, - ], - }), - stderr: '', - exitCode: 0, - timedOut: false, - }); - - const result = await StaticAnalysisService.analyze( - ['src/app.ts'], - repoRoot, - createConfig() - ); - - expect(result.summary.warnings.some((warning) => warning.includes('Semgrep engine warning'))).toBe( - true - ); - expect( - result.summary.warnings.some((warning) => warning.includes('javascript.some.rule')) - ).toBe(true); - }); - - it('suppresses Pro-engine-only Semgrep warnings while keeping actionable warnings', async () => { - mockedSpawnAsync.mockResolvedValue({ - stdout: JSON.stringify({ - results: [], - errors: [ - { - rule_id: 'javascript.pro.only', - path: 'src/app.ts', - message: 'metavariable-name:module(s) operator is only supported in the Pro engine', - }, - { - rule_id: 'javascript.actionable', - path: 'src/app.ts', - message: 'Internal matching error: parser timeout', - }, - ], - }), - stderr: '', - exitCode: 0, - timedOut: false, - }); - - const result = await StaticAnalysisService.analyze( - ['src/app.ts'], - repoRoot, - createConfig() - ); - - expect( - result.summary.warnings.some((warning) => warning.includes('javascript.pro.only')) - ).toBe(false); - expect( - result.summary.warnings.some((warning) => warning.includes('javascript.actionable')) - ).toBe(true); - }); - it('always excludes test files regardless of configured excludedPaths', async () => { - mockedSpawnAsync.mockResolvedValue({ - stdout: JSON.stringify({ results: [] }), - stderr: '', - exitCode: 0, - timedOut: false, - }); - const result = await StaticAnalysisService.analyze( ['src/app.ts', 'src/app.test.ts'], repoRoot, createConfig({ excludedPaths: [] }) ); - expect(mockedSpawnAsync).toHaveBeenCalledWith( - '/usr/bin/semgrep', - ['scan', '--json', '--quiet', '--oss-only', '--config', 'auto', 'src/app.ts'], - { cwd: repoRoot, timeout: 30_000 } - ); + expect(mockedSpawnAsync).not.toHaveBeenCalled(); expect(result.summary.skippedFiles.excluded).toContain('src/app.test.ts'); }); @@ -225,31 +123,68 @@ describe('StaticAnalysisService', () => { }); 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({ - results: [ - { - check_id: 'rule.info', - path: 'src/app.ts', - start: { line: 1, col: 1 }, - extra: { message: 'info', severity: 'INFO', metadata: {} }, - }, - { - check_id: 'rule.error.one', - path: 'src/app.ts', - start: { line: 2, col: 1 }, - extra: { message: 'err1', severity: 'ERROR', metadata: {} }, - }, + runs: [ { - check_id: 'rule.error.two', - path: 'src/app.ts', - start: { line: 3, col: 1 }, - extra: { message: 'err2', severity: 'ERROR', metadata: {} }, + 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: 1, + exitCode: 0, timedOut: false, }); @@ -257,6 +192,20 @@ describe('StaticAnalysisService', () => { ['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, }) @@ -268,7 +217,7 @@ describe('StaticAnalysisService', () => { expect(result.summary.truncated).toBe(true); }); - it('runs qlty when enabled and available (with semgrep disabled in this test)', async () => { + it('runs qlty check when enabled and available with smells disabled', async () => { vi.mocked(ToolInstaller.ensureAvailable).mockResolvedValue({ semgrep: { available: true, @@ -320,10 +269,16 @@ describe('StaticAnalysisService', () => { 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'], @@ -334,10 +289,131 @@ describe('StaticAnalysisService', () => { 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('does not run qlty when qlty is enabled but unavailable', async () => { @@ -353,12 +429,6 @@ describe('StaticAnalysisService', () => { installedNow: false, }, }); - mockedSpawnAsync.mockResolvedValue({ - stdout: JSON.stringify({ results: [] }), - stderr: '', - exitCode: 0, - timedOut: false, - }); const result = await StaticAnalysisService.analyze( ['src/app.ts'], @@ -372,6 +442,11 @@ describe('StaticAnalysisService', () => { qlty: { enabled: true, timeout: 30_000, + smells: { + enabled: true, + timeout: 30_000, + includeDuplication: true, + }, }, }) ); @@ -381,7 +456,7 @@ describe('StaticAnalysisService', () => { expect(result.summary.warnings).not.toContain('Qlty runner is not implemented yet; qlty scan skipped.'); }); - it('keeps semgrep unavailable warning text unchanged', async () => { + it('does not add semgrep unavailable warnings while semgrep execution is disabled', async () => { vi.mocked(ToolInstaller.ensureAvailable).mockResolvedValue({ semgrep: { available: false, @@ -403,7 +478,7 @@ describe('StaticAnalysisService', () => { expect(mockedSpawnAsync).not.toHaveBeenCalled(); expect(result.summary.available.semgrep).toBe(false); - expect(result.summary.warnings).toContain('Semgrep is not available; semgrep scan skipped.'); + expect(result.summary.warnings).not.toContain('Semgrep is not available; semgrep scan skipped.'); }); it('returns immediately when static analysis is disabled', async () => { diff --git a/src/services/static-analysis/__tests__/tool-runner-registry.test.ts b/src/services/static-analysis/__tests__/tool-runner-registry.test.ts index f298f2e..b5f17c2 100644 --- a/src/services/static-analysis/__tests__/tool-runner-registry.test.ts +++ b/src/services/static-analysis/__tests__/tool-runner-registry.test.ts @@ -6,6 +6,7 @@ function createConfig(overrides: Partial = {}): StaticAnal return { enabled: true, autoInstall: false, + autoInstallSystemDeps: false, excludedPaths: [], semgrep: { enabled: true, @@ -15,6 +16,11 @@ function createConfig(overrides: Partial = {}): StaticAnal qlty: { enabled: false, timeout: 30_000, + smells: { + enabled: true, + timeout: 30_000, + includeDuplication: true, + }, }, maxFindings: 500, minSeverity: 'warning', @@ -39,41 +45,28 @@ function createAvailability(overrides: Partial = {}): ToolAvai } describe('buildToolRunnerPlans', () => { - it('marks semgrep runnable when enabled and available', () => { + it('keeps semgrep non-runnable even 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('returns unchanged semgrep unavailable warning when semgrep is enabled but missing', () => { - const plans = buildToolRunnerPlans( - createConfig(), - createAvailability({ - semgrep: { - available: false, - executable: null, - installedNow: false, - }, - }) - ); - const semgrepPlan = plans[0]; - expect(semgrepPlan.runnable).toBe(false); + expect(semgrepPlan.warnings).toEqual([]); expect(semgrepPlan.createRunner).toBeNull(); - expect(semgrepPlan.warnings).toEqual(['Semgrep is not available; semgrep scan skipped.']); }); - it('marks qlty runnable when qlty is enabled and available', () => { + 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({ @@ -84,43 +77,66 @@ describe('buildToolRunnerPlans', () => { }, }) ); - const qltyPlan = plans[1]; - expect(qltyPlan.source).toBe('qlty'); - expect(qltyPlan.runnable).toBe(true); - expect(qltyPlan.warnings).toEqual([]); - expect(qltyPlan.createRunner).not.toBeNull(); - expect(qltyPlan.createRunner?.().name).toBe('qlty'); + 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 warn about qlty when qlty is enabled but unavailable', () => { + it('does not run qlty smells when smells are disabled', () => { const plans = buildToolRunnerPlans( createConfig({ qlty: { enabled: true, - timeout: 15_000, + timeout: 30_000, + smells: { + enabled: false, + timeout: 30_000, + includeDuplication: true, + }, }, }), createAvailability({ qlty: { - available: false, - executable: null, + available: true, + executable: '/usr/bin/qlty', installedNow: false, }, }) ); - const qltyPlan = plans[1]; - expect(qltyPlan.runnable).toBe(false); - expect(qltyPlan.warnings).toEqual([]); + 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 when qlty is enabled but unavailable with explicit error', () => { + 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({ @@ -132,15 +148,21 @@ describe('buildToolRunnerPlans', () => { }, }) ); - const qltyPlan = plans[1]; - expect(qltyPlan.runnable).toBe(false); - expect(qltyPlan.createRunner).toBeNull(); - expect(qltyPlan.warnings).toEqual(['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', () => { + it('keeps plan order semgrep then qlty-check then qlty-smells', () => { const plans = buildToolRunnerPlans(createConfig(), createAvailability()); - expect(plans.map((plan) => plan.source)).toEqual(['semgrep', 'qlty']); + expect(plans.map((plan) => plan.id)).toEqual(['semgrep', 'qlty-check', 'qlty-smells']); }); }); diff --git a/src/services/static-analysis/index.ts b/src/services/static-analysis/index.ts index e8da67d..35a4999 100644 --- a/src/services/static-analysis/index.ts +++ b/src/services/static-analysis/index.ts @@ -4,4 +4,5 @@ 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'; 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/semgrep-runner.ts b/src/services/static-analysis/semgrep-runner.ts index 9905184..96d0fb0 100644 --- a/src/services/static-analysis/semgrep-runner.ts +++ b/src/services/static-analysis/semgrep-runner.ts @@ -163,6 +163,7 @@ export class SemgrepRunner implements IToolRunner { return { source: 'semgrep', + runnerId: 'semgrep', file, line, column, diff --git a/src/services/static-analysis/static-analysis.service.ts b/src/services/static-analysis/static-analysis.service.ts index f427788..4316f14 100644 --- a/src/services/static-analysis/static-analysis.service.ts +++ b/src/services/static-analysis/static-analysis.service.ts @@ -77,12 +77,10 @@ export class StaticAnalysisService { } const toolRunnerPlans = buildToolRunnerPlans(config, availability); - const semgrepPlan = toolRunnerPlans.find((plan) => plan.source === 'semgrep'); - const qltyPlan = toolRunnerPlans.find((plan) => plan.source === 'qlty'); effectiveAvailability = { - semgrep: semgrepPlan?.runnable ?? false, - qlty: qltyPlan?.runnable ?? false, + semgrep: toolRunnerPlans.some((plan) => plan.source === 'semgrep' && plan.runnable), + qlty: toolRunnerPlans.some((plan) => plan.source === 'qlty' && plan.runnable), }; for (const plan of toolRunnerPlans) { @@ -114,16 +112,9 @@ export class StaticAnalysisService { }; } - const runners: IToolRunner[] = []; - const toolPlanBySource = new Map(toolRunnerPlans.map((plan) => [plan.source, plan])); + const runnablePlans = toolRunnerPlans.filter((plan) => plan.runnable && plan.createRunner); - for (const plan of toolRunnerPlans) { - if (plan.runnable && plan.createRunner) { - runners.push(plan.createRunner()); - } - } - - if (runners.length === 0) { + if (runnablePlans.length === 0) { warnings.push('No static analysis runners available to execute.'); const totalMs = Date.now() - startTime; return { @@ -141,30 +132,37 @@ export class StaticAnalysisService { }; } - let semgrepMs: number | null = null; - let qltyMs: number | null = null; + let semgrepMs = 0; + let qltyMs = 0; + let sawSemgrep = false; + let sawQlty = false; const allFindings: StaticAnalysisFinding[] = []; - for (const runner of runners) { + for (const plan of runnablePlans) { + const runner: IToolRunner = plan.createRunner!(); const toolStart = Date.now(); try { - const timeout = - toolPlanBySource.get(runner.name)?.timeoutMs ?? - (runner.name === 'semgrep' ? config.semgrep.timeout : config.qlty.timeout); - const result = await runner.scan(scope.scanFiles, repoPath, timeout); + const result = await runner.scan(scope.scanFiles, repoPath, plan.timeoutMs); + const elapsedMs = Date.now() - toolStart; allFindings.push(...result.findings); - if (runner.name === 'semgrep') { - semgrepMs = Date.now() - toolStart; + if (plan.source === 'semgrep') { + sawSemgrep = true; + semgrepMs += elapsedMs; rawOutputs.semgrep = result.rawOutput; warnings.push(...this.extractSemgrepEngineWarnings(result.rawOutput.stdout)); - } else if (runner.name === 'qlty') { - qltyMs = Date.now() - toolStart; + } else if (plan.id === 'qlty-check') { + sawQlty = true; + qltyMs += elapsedMs; rawOutputs.qlty = result.rawOutput; + } else if (plan.id === 'qlty-smells') { + sawQlty = true; + qltyMs += elapsedMs; + rawOutputs.qltySmells = result.rawOutput; } } catch (error) { warnings.push( - `${runner.name} scan failed: ${error instanceof Error ? error.message : String(error)}` + `${plan.id} scan failed: ${error instanceof Error ? error.message : String(error)}` ); } } @@ -182,7 +180,9 @@ export class StaticAnalysisService { } postProcessMs = Date.now() - postProcessStart; const totalMs = Date.now() - startTime; - const runnerMs = (semgrepMs || 0) + (qltyMs || 0); + const semgrepTiming = sawSemgrep ? semgrepMs : null; + const qltyTiming = sawQlty ? qltyMs : null; + const runnerMs = (semgrepTiming || 0) + (qltyTiming || 0); const overheadMs = Math.max( 0, totalMs - toolAvailabilityMs - scopeResolutionMs - postProcessMs - runnerMs @@ -191,8 +191,8 @@ export class StaticAnalysisService { const summary: StaticAnalysisSummary = { available: effectiveAvailability, timing: { - semgrepMs, - qltyMs, + semgrepMs: semgrepTiming, + qltyMs: qltyTiming, toolAvailabilityMs, scopeResolutionMs, postProcessMs, @@ -239,6 +239,15 @@ export class StaticAnalysisService { } ); + 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 }); } @@ -248,8 +257,12 @@ export class StaticAnalysisService { } for (const finding of summary.findings) { + const runnerLabel = + finding.runnerId === finding.source + ? finding.source + : `${finding.source}:${finding.runnerId}`; LoggerService.info( - `[${finding.severity}][${finding.category}][${finding.source}] ${finding.file}:${finding.line}:${finding.column} ${finding.ruleId} - ${finding.message}`, + `[${finding.severity}][${finding.category}][${runnerLabel}] ${finding.file}:${finding.line}:${finding.column} ${finding.ruleId} - ${finding.message}`, { category: STATIC_ANALYSIS_LOG_CATEGORY } ); } @@ -291,6 +304,11 @@ export class StaticAnalysisService { info: 0, byCategory: {}, byFile: {}, + byRunner: { + semgrep: 0, + 'qlty-check': 0, + 'qlty-smells': 0, + }, }, findings: [], truncated: false, @@ -415,6 +433,7 @@ export class StaticAnalysisService { const unique = new Map(); for (const finding of findings) { const key = [ + finding.runnerId, finding.source, finding.file, finding.line, @@ -468,6 +487,11 @@ export class StaticAnalysisService { 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) { @@ -481,6 +505,7 @@ export class StaticAnalysisService { 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-runner-registry.ts b/src/services/static-analysis/tool-runner-registry.ts index 318f248..d753297 100644 --- a/src/services/static-analysis/tool-runner-registry.ts +++ b/src/services/static-analysis/tool-runner-registry.ts @@ -1,5 +1,5 @@ -import { SemgrepRunner } from './semgrep-runner'; import { QltyRunner } from './qlty-runner'; +import { QltySmellsRunner } from './qlty-smells-runner'; import { FindingSource, IToolRunner, @@ -7,7 +7,10 @@ import { ToolAvailability, } from './types'; +export type ToolRunnerPlanId = 'semgrep' | 'qlty-check' | 'qlty-smells'; + export interface ToolRunnerPlan { + id: ToolRunnerPlanId; source: FindingSource; runnable: boolean; timeoutMs: number; @@ -20,36 +23,27 @@ export function buildToolRunnerPlans( availability: ToolAvailability ): ToolRunnerPlan[] { const semgrepPlan = buildSemgrepPlan(config, availability); - const qltyPlan = buildQltyPlan(config, availability); - return [semgrepPlan, qltyPlan]; + const qltyCheckPlan = buildQltyCheckPlan(config, availability); + const qltySmellsPlan = buildQltySmellsPlan(config, availability); + return [semgrepPlan, qltyCheckPlan, qltySmellsPlan]; } function buildSemgrepPlan( config: StaticAnalysisConfig, - availability: ToolAvailability + _availability: ToolAvailability ): ToolRunnerPlan { - const runnable = Boolean( - config.semgrep.enabled && availability.semgrep.available && availability.semgrep.executable - ); - const warnings: string[] = []; - - if (config.semgrep.enabled && !availability.semgrep.available) { - warnings.push(availability.semgrep.error || 'Semgrep is not available; semgrep scan skipped.'); - } - + // Temporary rollout mode: keep semgrep out of evaluation while qlty is stabilized. return { + id: 'semgrep', source: 'semgrep', - runnable, + runnable: false, timeoutMs: config.semgrep.timeout, - warnings, - createRunner: - runnable && availability.semgrep.executable - ? () => new SemgrepRunner(availability.semgrep.executable!, config.semgrep.rules) - : null, + warnings: [], + createRunner: null, }; } -function buildQltyPlan( +function buildQltyCheckPlan( config: StaticAnalysisConfig, availability: ToolAvailability ): ToolRunnerPlan { @@ -63,6 +57,7 @@ function buildQltyPlan( } return { + id: 'qlty-check', source: 'qlty', runnable, timeoutMs: config.qlty.timeout, @@ -73,3 +68,30 @@ function buildQltyPlan( : null, }; } + +function buildQltySmellsPlan( + config: StaticAnalysisConfig, + availability: ToolAvailability +): ToolRunnerPlan { + const runnable = Boolean( + config.qlty.enabled && + config.qlty.smells.enabled && + availability.qlty.available && + availability.qlty.executable + ); + + return { + id: 'qlty-smells', + source: 'qlty', + runnable, + timeoutMs: config.qlty.smells.timeout, + warnings: [], + createRunner: + runnable && availability.qlty.executable + ? () => + new QltySmellsRunner(availability.qlty.executable!, { + includeDuplication: config.qlty.smells.includeDuplication, + }) + : null, + }; +} diff --git a/src/services/static-analysis/types.ts b/src/services/static-analysis/types.ts index bdf825b..a53649f 100644 --- a/src/services/static-analysis/types.ts +++ b/src/services/static-analysis/types.ts @@ -2,6 +2,7 @@ export type FindingSeverity = 'error' | 'warning' | 'info'; export type FindingSource = 'semgrep' | 'qlty'; +export type FindingRunnerId = 'semgrep' | 'qlty-check' | 'qlty-smells'; export type FindingCategory = | 'security' | 'quality' @@ -22,6 +23,7 @@ export const SEVERITY_RANK: Record = { export interface StaticAnalysisFinding { source: FindingSource; + runnerId: FindingRunnerId; file: string; line: number; column: number; @@ -88,6 +90,7 @@ export interface StaticAnalysisSummary { info: number; byCategory: Record; byFile: Record; + byRunner: Record; }; findings: StaticAnalysisFinding[]; truncated: boolean; @@ -115,6 +118,12 @@ export interface RawToolOutput { exitCode: number | null; timedOut: boolean; }; + qltySmells?: { + stdout: string; + stderr: string; + exitCode: number | null; + timedOut: boolean; + }; } // --- Config ---------------------------------------------------------- @@ -123,6 +132,8 @@ 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: { @@ -136,6 +147,12 @@ export interface StaticAnalysisConfig { enabled: boolean; /** Milliseconds */ timeout: number; + smells: { + enabled: boolean; + /** Milliseconds */ + timeout: number; + includeDuplication: boolean; + }; }; /** Cap total findings (safety against token bloat) */ maxFindings: number; From ce21c79e9886a9d3f0b957cb72d3674908cb9537 Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Wed, 18 Feb 2026 14:50:31 +0100 Subject: [PATCH 13/24] fix(static-analysis): improve qlty auto-install reliability --- .gitignore | 12 + .qlty/.gitignore | 7 + .qlty/qlty.toml | 97 +++ docs/CONFIGURATION.md | 29 +- .../__tests__/tool-installation.test.ts | 483 ++++++++++++++ .../__tests__/tool-installer.test.ts | 24 + .../static-analysis.service.ts | 5 +- .../static-analysis/tool-installation.ts | 626 +++++++++++++++++- .../static-analysis/tool-installer-cache.ts | 10 +- .../static-analysis/tool-installer.ts | 56 +- 10 files changed, 1308 insertions(+), 41 deletions(-) create mode 100644 .qlty/.gitignore create mode 100644 .qlty/qlty.toml create mode 100644 src/services/static-analysis/__tests__/tool-installation.test.ts diff --git a/.gitignore b/.gitignore index e641279..76bbbb9 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,15 @@ coverage .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/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 6c6a607..284437a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -387,10 +387,16 @@ codewave config set verbose true `staticAnalysis` configuration is validated at runtime when `.codewave.config.json` is loaded. If values are invalid, CodeWave fails fast and reports field-level errors. +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 +- `staticAnalysis.autoInstallSystemDeps`: boolean - `staticAnalysis.excludedPaths`: array of non-empty strings - `staticAnalysis.semgrep.enabled`: boolean - `staticAnalysis.semgrep.rules`: array of non-empty strings @@ -398,6 +404,9 @@ If values are invalid, CodeWave fails fast and reports field-level errors. - `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` @@ -408,6 +417,7 @@ If values are invalid, CodeWave fails fast and reports field-level errors. "staticAnalysis": { "enabled": true, "autoInstall": false, + "autoInstallSystemDeps": false, "excludedPaths": ["node_modules/**", "dist/**"], "semgrep": { "enabled": true, @@ -415,8 +425,13 @@ If values are invalid, CodeWave fails fast and reports field-level errors. "timeout": 60000 }, "qlty": { - "enabled": false, - "timeout": 60000 + "enabled": true, + "timeout": 60000, + "smells": { + "enabled": true, + "timeout": 60000, + "includeDuplication": true + } }, "maxFindings": 500, "minSeverity": "warning" @@ -430,11 +445,21 @@ If values are invalid, CodeWave fails fast and reports field-level errors. { "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" } 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 index 51e7782..0c54a31 100644 --- a/src/services/static-analysis/__tests__/tool-installer.test.ts +++ b/src/services/static-analysis/__tests__/tool-installer.test.ts @@ -138,6 +138,30 @@ describe('ToolInstaller', () => { 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'; diff --git a/src/services/static-analysis/static-analysis.service.ts b/src/services/static-analysis/static-analysis.service.ts index 4316f14..4d79d19 100644 --- a/src/services/static-analysis/static-analysis.service.ts +++ b/src/services/static-analysis/static-analysis.service.ts @@ -67,7 +67,10 @@ export class StaticAnalysisService { const availabilityStart = Date.now(); try { - availability = await ToolInstaller.ensureAvailable(config.autoInstall); + availability = await ToolInstaller.ensureAvailable( + config.autoInstall, + config.autoInstallSystemDeps + ); } catch (error) { warnings.push( `Tool availability check failed: ${error instanceof Error ? error.message : String(error)}` diff --git a/src/services/static-analysis/tool-installation.ts b/src/services/static-analysis/tool-installation.ts index e96a469..0830be0 100644 --- a/src/services/static-analysis/tool-installation.ts +++ b/src/services/static-analysis/tool-installation.ts @@ -18,8 +18,38 @@ 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, @@ -109,46 +139,267 @@ export function installQlty(deps: ToolInstallationDependencies): InstallResult { ensureDirectory(ToolPaths.getBinDir()); - const installCommand = - process.platform === 'win32' - ? { - cmd: 'powershell', - args: [ - '-NoProfile', - '-ExecutionPolicy', - 'Bypass', - '-Command', - 'iwr -useb https://qlty.sh | iex', - ], + 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, } - : { - cmd: 'sh', - args: ['-c', 'curl -fsSL https://qlty.sh | sh'], - }; + ); + } + } - const installResult = deps.runCommand(installCommand.cmd, installCommand.args); 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: formatCommandError(installCommand.cmd, installCommand.args, installResult), + error: + 'Qlty installer completed, but executable was not found in PATH or known install locations.', }; } - const discoveredExecutable = - deps.findExecutable('qlty') || - findExecutableAt(path.join(os.homedir(), '.qlty', 'bin', ToolPaths.binaryName('qlty'))) || - findExecutableAt(path.join(os.homedir(), '.local', 'bin', ToolPaths.binaryName('qlty'))); + 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 installer completed, but executable was not found in PATH or known install locations.', + '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) { @@ -164,6 +415,195 @@ export function installQlty(deps: ToolInstallationDependencies): InstallResult { 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' @@ -218,6 +658,148 @@ function formatCommandError( 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 }); } diff --git a/src/services/static-analysis/tool-installer-cache.ts b/src/services/static-analysis/tool-installer-cache.ts index eb3bdec..806e02f 100644 --- a/src/services/static-analysis/tool-installer-cache.ts +++ b/src/services/static-analysis/tool-installer-cache.ts @@ -1,8 +1,5 @@ import { ToolAvailability } from './types'; -const AUTO_INSTALL_CACHE_KEY = 'auto-install-enabled'; -const NO_AUTO_INSTALL_CACHE_KEY = 'auto-install-disabled'; - type AvailabilityComputer = () => ToolAvailability | Promise; export class ToolInstallerCache { @@ -16,9 +13,10 @@ export class ToolInstallerCache { static async getOrCompute( autoInstall: boolean, + autoInstallSystemDeps: boolean, computeAvailability: AvailabilityComputer ): Promise { - const cacheKey = ToolInstallerCache.getCacheKey(autoInstall); + const cacheKey = ToolInstallerCache.getCacheKey(autoInstall, autoInstallSystemDeps); const cachedAvailability = ToolInstallerCache.cachedAvailabilityByMode.get(cacheKey); if (cachedAvailability) { @@ -43,7 +41,7 @@ export class ToolInstallerCache { return availabilityPromise; } - private static getCacheKey(autoInstall: boolean): string { - return autoInstall ? AUTO_INSTALL_CACHE_KEY : NO_AUTO_INSTALL_CACHE_KEY; + 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 index 33cf635..6d3754f 100644 --- a/src/services/static-analysis/tool-installer.ts +++ b/src/services/static-analysis/tool-installer.ts @@ -1,4 +1,5 @@ import * as childProcess from 'child_process'; +import { LoggerService } from '../../common/logger'; import { ToolAvailability, ToolStatus } from './types'; import { findExecutable as resolveExecutable, @@ -13,6 +14,8 @@ import { 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, { @@ -22,7 +25,6 @@ export class ToolInstaller { }; private static commandRunner: CommandRunner = ToolInstaller.defaultCommandRunner; - /** * Test helper to clear process-level cache between tests. */ @@ -58,30 +60,44 @@ export class ToolInstaller { }); } - static installQlty(): InstallResult { + 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): Promise { - return ToolInstallerCache.getOrCompute(autoInstall, () => - ToolInstaller.computeAvailability(autoInstall) + static async ensureAvailable( + autoInstall: boolean, + autoInstallSystemDeps = false + ): Promise { + return ToolInstallerCache.getOrCompute(autoInstall, autoInstallSystemDeps, () => + ToolInstaller.computeAvailability(autoInstall, autoInstallSystemDeps) ); } - private static computeAvailability(autoInstall: boolean): ToolAvailability { + private static computeAvailability( + autoInstall: boolean, + autoInstallSystemDeps: boolean + ): ToolAvailability { return { - semgrep: ToolInstaller.resolveTool('semgrep', autoInstall), - qlty: ToolInstaller.resolveTool('qlty', autoInstall), + semgrep: ToolInstaller.resolveTool('semgrep', autoInstall, autoInstallSystemDeps), + qlty: ToolInstaller.resolveTool('qlty', autoInstall, autoInstallSystemDeps), }; } - private static resolveTool(toolName: 'semgrep' | 'qlty', autoInstall: boolean): ToolStatus { + 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, @@ -90,6 +106,9 @@ export class ToolInstaller { } if (!autoInstall) { + LoggerService.warn(`${toolName} is missing and auto-install is disabled.`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); return { available: false, executable: null, @@ -98,10 +117,21 @@ export class ToolInstaller { }; } + LoggerService.info(`${toolName} not found. Starting auto-install flow.`, { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); const installResult = - toolName === 'semgrep' ? ToolInstaller.installSemgrep() : ToolInstaller.installQlty(); + 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, @@ -109,6 +139,12 @@ export class ToolInstaller { }; } + LoggerService.warn( + `${toolName} auto-install failed: ${installResult.error || `Failed to install ${toolName}`}`, + { + category: STATIC_ANALYSIS_LOG_CATEGORY, + } + ); return { available: false, executable: null, From 0b102e4b78e4752a9c5eb1d2a97a051245e38382 Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Wed, 18 Feb 2026 18:11:35 +0100 Subject: [PATCH 14/24] feat(static-analysis): scope findings to changed lines - pass commitDiff into static-analysis flow and filter findings by changed line ranges - keep semgrep and qlty execution hybrid (semgrep parallel with serialized qlty pipeline) - add broad default excludes for docs and multi-ecosystem lockfiles - improve raw artifact readability by storing parsed stdout/stderr structures --- cli/utils/__tests__/shared.utils.test.ts | 67 +++ cli/utils/shared.utils.ts | 118 ++++- .../config-loader.validation.test.ts | 5 + src/config/config-loader.ts | 25 +- src/config/default-config.ts | 104 +++- .../commit-evaluation-orchestrator.ts | 5 +- .../__tests__/changed-lines-scope.test.ts | 84 ++++ .../__tests__/static-analysis.service.test.ts | 458 +++++++++++++++++- .../__tests__/tool-runner-registry.test.ts | 40 +- .../static-analysis/changed-lines-scope.ts | 211 ++++++++ .../static-analysis.service.ts | 194 ++++++-- .../static-analysis/tool-runner-registry.ts | 22 +- 12 files changed, 1240 insertions(+), 93 deletions(-) create mode 100644 cli/utils/__tests__/shared.utils.test.ts create mode 100644 src/services/static-analysis/__tests__/changed-lines-scope.test.ts create mode 100644 src/services/static-analysis/changed-lines-scope.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/shared.utils.ts b/cli/utils/shared.utils.ts index 363b6af..4e76aa2 100644 --- a/cli/utils/shared.utils.ts +++ b/cli/utils/shared.utils.ts @@ -247,20 +247,23 @@ async function writeStaticAnalysisArtifacts( if (rawOutputs.semgrep) { await fs.writeFile( path.join(staticDir, 'raw-semgrep.json'), - JSON.stringify(rawOutputs.semgrep, null, 2) + 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(rawOutputs.qlty, null, 2)); + 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(rawOutputs.qltySmells, null, 2) + JSON.stringify(formatRawOutputForArtifact(rawOutputs.qltySmells), null, 2) ); references.rawQltySmells = 'static-analysis/raw-qlty-smells.json'; } @@ -268,6 +271,115 @@ async function writeStaticAnalysisArtifacts( 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/src/config/__tests__/config-loader.validation.test.ts b/src/config/__tests__/config-loader.validation.test.ts index 84a89c0..21fb46b 100644 --- a/src/config/__tests__/config-loader.validation.test.ts +++ b/src/config/__tests__/config-loader.validation.test.ts @@ -44,6 +44,11 @@ describe('loadConfig validation behavior', () => { 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', () => { diff --git a/src/config/config-loader.ts b/src/config/config-loader.ts index 2db3f10..4056fbe 100644 --- a/src/config/config-loader.ts +++ b/src/config/config-loader.ts @@ -3,7 +3,7 @@ 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'; @@ -29,28 +29,7 @@ export function loadConfig(configPath?: string): AppConfig | null { enabled: true, autoInstall: true, autoInstallSystemDeps: false, - excludedPaths: [ - '.git/**', - 'node_modules/**', - '.venv/**', - 'venv/**', - 'env/**', - 'dist/**', - 'build/**', - 'out/**', - 'coverage/**', - '.next/**', - '.nuxt/**', - '.cache/**', - '.turbo/**', - '**/__tests__/**', - '**/test/**', - '**/tests/**', - '**/*.test.*', - '**/*.spec.*', - '**/*_test.*', - '**/*_spec.*', - ], + excludedPaths: [...STATIC_ANALYSIS_DEFAULT_EXCLUDED_PATHS], semgrep: { enabled: true, rules: ['auto'], diff --git a/src/config/default-config.ts b/src/config/default-config.ts index 7e8b3f9..3822f07 100644 --- a/src/config/default-config.ts +++ b/src/config/default-config.ts @@ -3,6 +3,87 @@ import { AppConfig } from './config.interface'; +export const STATIC_ANALYSIS_DEFAULT_EXCLUDED_PATHS = [ + '.git/**', + 'node_modules/**', + '.venv/**', + 'venv/**', + 'env/**', + 'dist/**', + 'build/**', + 'out/**', + 'coverage/**', + '.next/**', + '.nuxt/**', + '.cache/**', + '.turbo/**', + '**/__tests__/**', + '**/test/**', + '**/tests/**', + '**/*.test.*', + '**/*.spec.*', + '**/*_test.*', + '**/*_spec.*', + // Documentation files are low-signal for static analyzers and expensive at scale. + 'docs/**', + '**/*.md', + '**/*.markdown', + '**/*.mdx', + '**/*.rst', + '**/*.adoc', + '**/*.asciidoc', + // 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: '', @@ -56,28 +137,7 @@ export const DEFAULT_CONFIG: AppConfig = { enabled: true, autoInstall: true, autoInstallSystemDeps: false, - excludedPaths: [ - '.git/**', - 'node_modules/**', - '.venv/**', - 'venv/**', - 'env/**', - 'dist/**', - 'build/**', - 'out/**', - 'coverage/**', - '.next/**', - '.nuxt/**', - '.cache/**', - '.turbo/**', - '**/__tests__/**', - '**/test/**', - '**/tests/**', - '**/*.test.*', - '**/*.spec.*', - '**/*_test.*', - '**/*_spec.*', - ], + excludedPaths: [...STATIC_ANALYSIS_DEFAULT_EXCLUDED_PATHS], semgrep: { enabled: true, rules: ['auto'], diff --git a/src/orchestrator/commit-evaluation-orchestrator.ts b/src/orchestrator/commit-evaluation-orchestrator.ts index d2574ff..2f16700 100644 --- a/src/orchestrator/commit-evaluation-orchestrator.ts +++ b/src/orchestrator/commit-evaluation-orchestrator.ts @@ -138,7 +138,10 @@ export class CommitEvaluationOrchestrator { staticAnalysis = await StaticAnalysisService.analyze( context.filesChanged || [], context.repoPath || process.cwd(), - staticAnalysisConfig + staticAnalysisConfig, + { + commitDiff: context.commitDiff, + } ); StaticAnalysisService.logOutput(staticAnalysis.summary, outputMode); } catch (error) { 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..e6bb3d9 --- /dev/null +++ b/src/services/static-analysis/__tests__/changed-lines-scope.test.ts @@ -0,0 +1,84 @@ +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); + }); +}); diff --git a/src/services/static-analysis/__tests__/static-analysis.service.test.ts b/src/services/static-analysis/__tests__/static-analysis.service.test.ts index 2ef9aaf..283bc48 100644 --- a/src/services/static-analysis/__tests__/static-analysis.service.test.ts +++ b/src/services/static-analysis/__tests__/static-analysis.service.test.ts @@ -4,7 +4,7 @@ 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 { StaticAnalysisConfig } from '../types'; +import { IToolRunner, StaticAnalysisConfig } from '../types'; import { spawnAsync } from '../spawn-helper'; import { ToolInstaller } from '../tool-installer'; import { StaticAnalysisService } from '../static-analysis.service'; @@ -41,6 +41,20 @@ function createConfig(overrides: Partial = {}): StaticAnal }; } +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; @@ -78,18 +92,42 @@ describe('StaticAnalysisService', () => { } }); - it('does not run semgrep during evaluation when qlty is disabled', async () => { + 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).not.toHaveBeenCalled(); - expect(result.summary.counts.total).toBe(0); - expect(result.summary.available.semgrep).toBe(false); + 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.warnings).toContain('No static analysis runners available to execute.'); expect(result.summary.timing.toolAvailabilityMs).toBeTypeOf('number'); expect(result.summary.timing.scopeResolutionMs).toBeTypeOf('number'); expect(result.summary.timing.postProcessMs).toBeTypeOf('number'); @@ -100,7 +138,14 @@ describe('StaticAnalysisService', () => { const result = await StaticAnalysisService.analyze( ['src/app.ts', 'src/app.test.ts'], repoRoot, - createConfig({ excludedPaths: [] }) + createConfig({ + excludedPaths: [], + semgrep: { + enabled: false, + rules: ['auto'], + timeout: 30_000, + }, + }) ); expect(mockedSpawnAsync).not.toHaveBeenCalled(); @@ -416,6 +461,170 @@ describe('StaticAnalysisService', () => { 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: { @@ -456,12 +665,13 @@ describe('StaticAnalysisService', () => { expect(result.summary.warnings).not.toContain('Qlty runner is not implemented yet; qlty scan skipped.'); }); - it('does not add semgrep unavailable warnings while semgrep execution is disabled', async () => { + 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, @@ -478,7 +688,237 @@ describe('StaticAnalysisService', () => { expect(mockedSpawnAsync).not.toHaveBeenCalled(); expect(result.summary.available.semgrep).toBe(false); - expect(result.summary.warnings).not.toContain('Semgrep is not available; semgrep scan skipped.'); + 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 () => { diff --git a/src/services/static-analysis/__tests__/tool-runner-registry.test.ts b/src/services/static-analysis/__tests__/tool-runner-registry.test.ts index b5f17c2..a7c2dbc 100644 --- a/src/services/static-analysis/__tests__/tool-runner-registry.test.ts +++ b/src/services/static-analysis/__tests__/tool-runner-registry.test.ts @@ -45,17 +45,55 @@ function createAvailability(overrides: Partial = {}): ToolAvai } describe('buildToolRunnerPlans', () => { - it('keeps semgrep non-runnable even when enabled and available', () => { + 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({ 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..f0b36c4 --- /dev/null +++ b/src/services/static-analysis/changed-lines-scope.ts @@ -0,0 +1,211 @@ +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; + + const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/); + if (!match) { + currentFile = null; + continue; + } + + currentFile = normalizeDiffPath(match[2]); + 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; + } + + const cleaned = filePath.trim().replace(/\\/g, '/').replace(/^(\+\+\+|---)\s+/, ''); + if (!cleaned || cleaned === '/dev/null') { + return null; + } + + const withoutPrefix = cleaned.replace(/^(a|b)\//, '').replace(/^\.\//, ''); + return withoutPrefix || null; +} + +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/static-analysis.service.ts b/src/services/static-analysis/static-analysis.service.ts index 4d79d19..4ae55ec 100644 --- a/src/services/static-analysis/static-analysis.service.ts +++ b/src/services/static-analysis/static-analysis.service.ts @@ -1,7 +1,11 @@ import { LoggerService } from '../../common/logger'; import { FileScopeResolver } from './file-scope-resolver'; import { ToolInstaller } from './tool-installer'; -import { buildToolRunnerPlans } from './tool-runner-registry'; +import { buildToolRunnerPlans, ToolRunnerPlan } from './tool-runner-registry'; +import { + findingMatchesChangedLines, + parseChangedLineRangesFromUnifiedDiff, +} from './changed-lines-scope'; import { FindingSeverity, IToolRunner, @@ -31,11 +35,29 @@ export interface StaticAnalysisResult { 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 + config: StaticAnalysisConfig, + input: StaticAnalysisInput = {} ): Promise { const startTime = Date.now(); const warnings: string[] = []; @@ -79,7 +101,7 @@ export class StaticAnalysisService { toolAvailabilityMs = Date.now() - availabilityStart; } - const toolRunnerPlans = buildToolRunnerPlans(config, availability); + const toolRunnerPlans = this.getToolRunnerPlans(config, availability); effectiveAvailability = { semgrep: toolRunnerPlans.some((plan) => plan.source === 'semgrep' && plan.runnable), @@ -140,38 +162,69 @@ export class StaticAnalysisService { 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 + ); - for (const plan of runnablePlans) { - const runner: IToolRunner = plan.createRunner!(); - const toolStart = Date.now(); - try { - const result = await runner.scan(scope.scanFiles, repoPath, plan.timeoutMs); - const elapsedMs = Date.now() - toolStart; - allFindings.push(...result.findings); - - if (plan.source === 'semgrep') { - sawSemgrep = true; - semgrepMs += elapsedMs; - rawOutputs.semgrep = result.rawOutput; - warnings.push(...this.extractSemgrepEngineWarnings(result.rawOutput.stdout)); - } else if (plan.id === 'qlty-check') { - sawQlty = true; - qltyMs += elapsedMs; - rawOutputs.qlty = result.rawOutput; - } else if (plan.id === 'qlty-smells') { - sawQlty = true; - qltyMs += elapsedMs; - rawOutputs.qltySmells = result.rawOutput; - } - } catch (error) { + 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: ${error instanceof Error ? error.message : String(error)}` + `${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 deduplicated = this.deduplicate(allFindings); + 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; @@ -185,7 +238,7 @@ export class StaticAnalysisService { const totalMs = Date.now() - startTime; const semgrepTiming = sawSemgrep ? semgrepMs : null; const qltyTiming = sawQlty ? qltyMs : null; - const runnerMs = (semgrepTiming || 0) + (qltyTiming || 0); + const runnerMs = Math.max(semgrepTiming || 0, qltyTiming || 0); const overheadMs = Math.max( 0, totalMs - toolAvailabilityMs - scopeResolutionMs - postProcessMs - runnerMs @@ -432,6 +485,89 @@ export class StaticAnalysisService { 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) { diff --git a/src/services/static-analysis/tool-runner-registry.ts b/src/services/static-analysis/tool-runner-registry.ts index d753297..2c7770a 100644 --- a/src/services/static-analysis/tool-runner-registry.ts +++ b/src/services/static-analysis/tool-runner-registry.ts @@ -1,5 +1,6 @@ import { QltyRunner } from './qlty-runner'; import { QltySmellsRunner } from './qlty-smells-runner'; +import { SemgrepRunner } from './semgrep-runner'; import { FindingSource, IToolRunner, @@ -30,16 +31,27 @@ export function buildToolRunnerPlans( function buildSemgrepPlan( config: StaticAnalysisConfig, - _availability: ToolAvailability + availability: ToolAvailability ): ToolRunnerPlan { - // Temporary rollout mode: keep semgrep out of evaluation while qlty is stabilized. + const runnable = Boolean( + config.semgrep.enabled && availability.semgrep.available && availability.semgrep.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: false, + runnable, timeoutMs: config.semgrep.timeout, - warnings: [], - createRunner: null, + warnings, + createRunner: + runnable && availability.semgrep.executable + ? () => new SemgrepRunner(availability.semgrep.executable!, config.semgrep.rules) + : null, }; } From e15ee17e0e079e6c3b7df0b18e64adb67b6cd252 Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Thu, 19 Feb 2026 11:34:55 +0100 Subject: [PATCH 15/24] refactor(static-analysis): extract shared qlty init logic and remove non-null assertions - Extract requiresQltyInit() and qltyInitCompleted() into qlty-init-helper.ts, eliminating duplicated code from qlty-runner and qlty-smells-runner - Remove ! non-null assertions in tool-runner-registry by capturing executable into a local const before the runnable boolean check --- .../static-analysis/qlty-init-helper.ts | 55 +++++++++++++++++++ src/services/static-analysis/qlty-runner.ts | 52 ++---------------- .../static-analysis/qlty-smells-runner.ts | 48 +--------------- .../static-analysis/tool-runner-registry.ts | 32 ++++------- 4 files changed, 73 insertions(+), 114 deletions(-) create mode 100644 src/services/static-analysis/qlty-init-helper.ts 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-runner.ts b/src/services/static-analysis/qlty-runner.ts index 09f53c6..70c064c 100644 --- a/src/services/static-analysis/qlty-runner.ts +++ b/src/services/static-analysis/qlty-runner.ts @@ -1,6 +1,5 @@ -import fs from 'fs'; -import path from 'path'; 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'; @@ -55,7 +54,7 @@ export class QltyRunner implements IToolRunner { return { findings: [], rawOutput }; } - if (this.requiresInit(rawOutput)) { + if (requiresQltyInit(rawOutput)) { LoggerService.info('Qlty project not initialized. Running `qlty init` and retrying once...', { category: STATIC_ANALYSIS_LOG_CATEGORY, }); @@ -71,7 +70,7 @@ export class QltyRunner implements IToolRunner { return { findings: [], rawOutput }; } - if (this.initCompleted(repoPath, initOutput)) { + if (qltyInitCompleted(repoPath, initOutput)) { rawOutput = await spawnAsync(this.executablePath, checkArgs, { cwd: repoPath, timeout, @@ -166,7 +165,7 @@ export class QltyRunner implements IToolRunner { return false; } - if (this.requiresInit(installOutput)) { + if (requiresQltyInit(installOutput)) { LoggerService.info( 'Qlty install requires project initialization. Running `qlty init` and retrying install once...', { category: STATIC_ANALYSIS_LOG_CATEGORY } @@ -183,7 +182,7 @@ export class QltyRunner implements IToolRunner { return false; } - if (!this.initCompleted(repoPath, initOutput)) { + if (!qltyInitCompleted(repoPath, initOutput)) { LoggerService.warn('Qlty init failed during check bootstrap; skipping retry.', { category: STATIC_ANALYSIS_LOG_CATEGORY, data: { @@ -226,45 +225,4 @@ export class QltyRunner implements IToolRunner { return `${this.executablePath}::${repoPath}`; } - private requiresInit(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 - ); - } - - private initCompleted(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-smells-runner.ts b/src/services/static-analysis/qlty-smells-runner.ts index 366b08a..ef52ac6 100644 --- a/src/services/static-analysis/qlty-smells-runner.ts +++ b/src/services/static-analysis/qlty-smells-runner.ts @@ -1,6 +1,5 @@ -import fs from 'fs'; -import path from 'path'; 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'; @@ -61,7 +60,7 @@ export class QltySmellsRunner implements IToolRunner { return { findings: [], rawOutput }; } - if (this.requiresInit(rawOutput)) { + if (requiresQltyInit(rawOutput)) { LoggerService.info('Qlty project not initialized. Running `qlty init` and retrying smells once...', { category: STATIC_ANALYSIS_LOG_CATEGORY, }); @@ -77,7 +76,7 @@ export class QltySmellsRunner implements IToolRunner { return { findings: [], rawOutput }; } - if (this.initCompleted(repoPath, initOutput)) { + if (qltyInitCompleted(repoPath, initOutput)) { rawOutput = await spawnAsync(this.executablePath, smellsArgs, { cwd: repoPath, timeout, @@ -120,45 +119,4 @@ export class QltySmellsRunner implements IToolRunner { return args; } - private requiresInit(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 - ); - } - - private initCompleted(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/tool-runner-registry.ts b/src/services/static-analysis/tool-runner-registry.ts index 2c7770a..ac7dc0f 100644 --- a/src/services/static-analysis/tool-runner-registry.ts +++ b/src/services/static-analysis/tool-runner-registry.ts @@ -33,9 +33,8 @@ function buildSemgrepPlan( config: StaticAnalysisConfig, availability: ToolAvailability ): ToolRunnerPlan { - const runnable = Boolean( - config.semgrep.enabled && availability.semgrep.available && availability.semgrep.executable - ); + 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) { @@ -49,9 +48,7 @@ function buildSemgrepPlan( timeoutMs: config.semgrep.timeout, warnings, createRunner: - runnable && availability.semgrep.executable - ? () => new SemgrepRunner(availability.semgrep.executable!, config.semgrep.rules) - : null, + runnable && executable ? () => new SemgrepRunner(executable, config.semgrep.rules) : null, }; } @@ -59,9 +56,8 @@ function buildQltyCheckPlan( config: StaticAnalysisConfig, availability: ToolAvailability ): ToolRunnerPlan { - const runnable = Boolean( - config.qlty.enabled && availability.qlty.available && availability.qlty.executable - ); + 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) { @@ -74,10 +70,7 @@ function buildQltyCheckPlan( runnable, timeoutMs: config.qlty.timeout, warnings, - createRunner: - runnable && availability.qlty.executable - ? () => new QltyRunner(availability.qlty.executable!) - : null, + createRunner: runnable && executable ? () => new QltyRunner(executable) : null, }; } @@ -85,11 +78,9 @@ 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 && - availability.qlty.executable + config.qlty.enabled && config.qlty.smells.enabled && availability.qlty.available && executable ); return { @@ -99,11 +90,8 @@ function buildQltySmellsPlan( timeoutMs: config.qlty.smells.timeout, warnings: [], createRunner: - runnable && availability.qlty.executable - ? () => - new QltySmellsRunner(availability.qlty.executable!, { - includeDuplication: config.qlty.smells.includeDuplication, - }) + runnable && executable + ? () => new QltySmellsRunner(executable, { includeDuplication: config.qlty.smells.includeDuplication }) : null, }; } From 5ae7ac058a6770f0ed502d142b4baae999dcc3b9 Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Thu, 19 Feb 2026 11:37:02 +0100 Subject: [PATCH 16/24] fix(static-analysis): increase semgrep default timeout to 120s Semgrep was timing out at 60s when running in parallel with qlty tools. Doubling the timeout to 120s gives semgrep enough headroom under concurrent load. --- src/config/default-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/default-config.ts b/src/config/default-config.ts index 3822f07..ebcaf70 100644 --- a/src/config/default-config.ts +++ b/src/config/default-config.ts @@ -141,7 +141,7 @@ export const DEFAULT_CONFIG: AppConfig = { semgrep: { enabled: true, rules: ['auto'], - timeout: 60000, + timeout: 120000, }, qlty: { enabled: true, From e488e26ab77eafa5f3d61f50289d9170b023dbe6 Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Thu, 19 Feb 2026 12:06:00 +0100 Subject: [PATCH 17/24] fix(git-utils): capture new (b/) path and handle C-quoted paths in diffs - extractFilesFromDiff now captures the b/ (new/renamed) path instead of a/, so renamed files are no longer silently dropped as missing on disk - Add a quoted-path branch for git C-quoted filenames (spaces, non-ASCII) - Add unquoteGitPath to decode octal byte sequences and simple escapes --- cli/utils/git-utils.ts | 68 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/cli/utils/git-utils.ts b/cli/utils/git-utils.ts index c62cd98..601ff43 100644 --- a/cli/utils/git-utils.ts +++ b/cli/utils/git-utils.ts @@ -83,17 +83,75 @@ 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 */ From 65e93df97ffe0c2f9e21585c6cf74438e4182f3c Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Thu, 19 Feb 2026 12:06:24 +0100 Subject: [PATCH 18/24] fix(changed-lines-scope): handle C-quoted diff paths and fix octal corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parse quoted diff --git headers: diff --git "a/..." "b/..." - Move unquoting before backslash normalization in normalizeDiffPath so octal escape sequences (\303\251) are not corrupted by the \ -> / pass - Add unquoteGitPath helper for octal byte sequences and simple escapes - Add test covering non-ASCII filename (é) round-trip through diff parsing --- .../__tests__/changed-lines-scope.test.ts | 21 ++++++ .../static-analysis/changed-lines-scope.ts | 74 ++++++++++++++++++- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/src/services/static-analysis/__tests__/changed-lines-scope.test.ts b/src/services/static-analysis/__tests__/changed-lines-scope.test.ts index e6bb3d9..5833856 100644 --- a/src/services/static-analysis/__tests__/changed-lines-scope.test.ts +++ b/src/services/static-analysis/__tests__/changed-lines-scope.test.ts @@ -81,4 +81,25 @@ index 1111111..2222222 100644 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); + }); }); diff --git a/src/services/static-analysis/changed-lines-scope.ts b/src/services/static-analysis/changed-lines-scope.ts index f0b36c4..abc2c92 100644 --- a/src/services/static-analysis/changed-lines-scope.ts +++ b/src/services/static-analysis/changed-lines-scope.ts @@ -30,13 +30,22 @@ export function parseChangedLineRangesFromUnifiedDiff(diff: string): ChangedLine hasDiffMarkers = true; currentNewLine = null; - const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/); + // 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[2]); + currentFile = normalizeDiffPath(match[1]); continue; } @@ -168,7 +177,20 @@ function normalizeDiffPath(filePath: string): string | null { return null; } - const cleaned = filePath.trim().replace(/\\/g, '/').replace(/^(\+\+\+|---)\s+/, ''); + 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; } @@ -177,6 +199,52 @@ function normalizeDiffPath(filePath: string): string | null { 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 []; From 2fb85c06ae6db16a7c202fd0c49107f7b4fa5a7b Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Thu, 19 Feb 2026 12:06:45 +0100 Subject: [PATCH 19/24] fix(tool-installer-cache): cache tools independently to allow retry on transient failures - Split cache into per-tool maps (cachedSemgrepByMode, cachedQltyByMode) - Only cache a tool when it is successfully available, so a transient failure (network down, install timeout) does not lock it out for the process lifetime while the other tool remains available - Update cache test to toStrictEqual since the cache now reconstructs the ToolAvailability object rather than returning the same reference --- .../__tests__/tool-installer.test.ts | 2 +- .../static-analysis/tool-installer-cache.ts | 41 ++++++++++++------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/src/services/static-analysis/__tests__/tool-installer.test.ts b/src/services/static-analysis/__tests__/tool-installer.test.ts index 0c54a31..c731617 100644 --- a/src/services/static-analysis/__tests__/tool-installer.test.ts +++ b/src/services/static-analysis/__tests__/tool-installer.test.ts @@ -111,7 +111,7 @@ describe('ToolInstaller', () => { const second = await ToolInstaller.ensureAvailable(false); expect(findSpy).toHaveBeenCalledTimes(2); - expect(second).toBe(first); + expect(second).toStrictEqual(first); }); it('does not reuse no-auto-install cache when autoInstall mode changes', async () => { diff --git a/src/services/static-analysis/tool-installer-cache.ts b/src/services/static-analysis/tool-installer-cache.ts index 806e02f..4f878dd 100644 --- a/src/services/static-analysis/tool-installer-cache.ts +++ b/src/services/static-analysis/tool-installer-cache.ts @@ -1,14 +1,16 @@ -import { ToolAvailability } from './types'; +import { ToolAvailability, ToolStatus } from './types'; type AvailabilityComputer = () => ToolAvailability | Promise; export class ToolInstallerCache { - private static cachedAvailabilityByMode: Map = new Map(); - private static inFlightAvailabilityByMode: Map> = new Map(); + private static cachedSemgrepByMode: Map = new Map(); + private static cachedQltyByMode: Map = new Map(); + private static inFlightByMode: Map> = new Map(); static reset(): void { - ToolInstallerCache.cachedAvailabilityByMode.clear(); - ToolInstallerCache.inFlightAvailabilityByMode.clear(); + ToolInstallerCache.cachedSemgrepByMode.clear(); + ToolInstallerCache.cachedQltyByMode.clear(); + ToolInstallerCache.inFlightByMode.clear(); } static async getOrCompute( @@ -18,26 +20,37 @@ export class ToolInstallerCache { ): Promise { const cacheKey = ToolInstallerCache.getCacheKey(autoInstall, autoInstallSystemDeps); - const cachedAvailability = ToolInstallerCache.cachedAvailabilityByMode.get(cacheKey); - if (cachedAvailability) { - return cachedAvailability; + // 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 }; } - const inFlightAvailability = ToolInstallerCache.inFlightAvailabilityByMode.get(cacheKey); - if (inFlightAvailability) { - return inFlightAvailability; + // Deduplicate concurrent calls for the same mode + const inFlight = ToolInstallerCache.inFlightByMode.get(cacheKey); + if (inFlight) { + return inFlight; } const availabilityPromise = Promise.resolve(computeAvailability()) .then((availability) => { - ToolInstallerCache.cachedAvailabilityByMode.set(cacheKey, 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.inFlightAvailabilityByMode.delete(cacheKey); + ToolInstallerCache.inFlightByMode.delete(cacheKey); }); - ToolInstallerCache.inFlightAvailabilityByMode.set(cacheKey, availabilityPromise); + ToolInstallerCache.inFlightByMode.set(cacheKey, availabilityPromise); return availabilityPromise; } From 7ac342885177e383b6ff03af3f9fbafb49bb9062 Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Thu, 19 Feb 2026 12:13:46 +0100 Subject: [PATCH 20/24] fix(static-analysis): handle escaped quoted git diff paths --- cli/utils/__tests__/git-utils.test.ts | 24 +++++++++++++++++++ cli/utils/git-utils.ts | 4 +++- .../__tests__/changed-lines-scope.test.ts | 20 ++++++++++++++++ .../static-analysis/changed-lines-scope.ts | 4 +++- 4 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 cli/utils/__tests__/git-utils.test.ts 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/git-utils.ts b/cli/utils/git-utils.ts index 601ff43..450286f 100644 --- a/cli/utils/git-utils.ts +++ b/cli/utils/git-utils.ts @@ -89,7 +89,9 @@ export function extractFilesFromDiff(diff: string): string[] { // 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\/([^"]+)"$/); + const quotedMatch = line.match( + /^diff --git "a\/(?:[^"\\]|\\.)*" "b\/((?:[^"\\]|\\.)+)"$/ + ); if (quotedMatch) { files.push(unquoteGitPath(quotedMatch[1])); continue; diff --git a/src/services/static-analysis/__tests__/changed-lines-scope.test.ts b/src/services/static-analysis/__tests__/changed-lines-scope.test.ts index 5833856..7770a45 100644 --- a/src/services/static-analysis/__tests__/changed-lines-scope.test.ts +++ b/src/services/static-analysis/__tests__/changed-lines-scope.test.ts @@ -102,4 +102,24 @@ index 1111111..2222222 100644 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/changed-lines-scope.ts b/src/services/static-analysis/changed-lines-scope.ts index abc2c92..7b6354d 100644 --- a/src/services/static-analysis/changed-lines-scope.ts +++ b/src/services/static-analysis/changed-lines-scope.ts @@ -32,7 +32,9 @@ export function parseChangedLineRangesFromUnifiedDiff(diff: string): ChangedLine // 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\/([^"]+)"$/); + const quotedMatch = line.match( + /^diff --git "a\/(?:[^"\\]|\\.)*" "b\/((?:[^"\\]|\\.)+)"$/ + ); if (quotedMatch) { currentFile = unquoteGitPath(quotedMatch[1]); continue; From 7d442e04a1b22be3518d6335e2299c02e5a68deb Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Thu, 19 Feb 2026 14:58:27 +0100 Subject: [PATCH 21/24] feat(static-analysis): move analyzer install to config init --- README.md | 3 +- cli/commands/__tests__/config.command.test.ts | 111 +++++++++++++++++ cli/commands/config.command.ts | 117 +++++++++++++++++- docs/CONFIGURATION.md | 8 +- .../config-loader.validation.test.ts | 1 + src/config/config-loader.ts | 2 +- src/config/default-config.ts | 2 +- .../commit-evaluation-orchestrator.ts | 32 +++++ .../__tests__/static-analysis.service.test.ts | 90 ++++++++++++++ .../static-analysis.service.ts | 22 +++- 10 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 cli/commands/__tests__/config.command.test.ts diff --git a/README.md b/README.md index 59f5d7e..4fcb597 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,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 - **Default Settings**: Configure batch size, output directory, and reporting preferences Configuration is stored securely and only needs to be done once. @@ -447,7 +448,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 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/config.command.ts b/cli/commands/config.command.ts index e294bfc..40af088 100644 --- a/cli/commands/config.command.ts +++ b/cli/commands/config.command.ts @@ -2,6 +2,8 @@ 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'; +import { STATIC_ANALYSIS_DEFAULT_EXCLUDED_PATHS } from '../../src/config/default-config'; const CONFIG_FILE = '.codewave.config.json'; @@ -47,7 +49,29 @@ 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, + 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', }, }; @@ -63,6 +87,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 +161,27 @@ async function initializeConfig(): Promise { if (existingConfig.tracing) { config.tracing = { ...config.tracing, ...existingConfig.tracing }; } + if (existingConfig.staticAnalysis) { + config.staticAnalysis = { + ...config.staticAnalysis, + ...existingConfig.staticAnalysis, + excludedPaths: Array.isArray(existingConfig.staticAnalysis.excludedPaths) + ? [...existingConfig.staticAnalysis.excludedPaths] + : [...config.staticAnalysis.excludedPaths], + 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 +470,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 +577,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/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 284437a..28e2c88 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -387,6 +387,12 @@ codewave config set verbose true `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. + For deterministic `qlty check` results across machines, commit the project-level qlty config: - `.qlty/qlty.toml` must be tracked in git @@ -395,7 +401,7 @@ For deterministic `qlty check` results across machines, commit the project-level **Constraints:** - `staticAnalysis.enabled`: boolean -- `staticAnalysis.autoInstall`: 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 diff --git a/src/config/__tests__/config-loader.validation.test.ts b/src/config/__tests__/config-loader.validation.test.ts index 21fb46b..4511d8d 100644 --- a/src/config/__tests__/config-loader.validation.test.ts +++ b/src/config/__tests__/config-loader.validation.test.ts @@ -40,6 +40,7 @@ describe('loadConfig validation behavior', () => { 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); diff --git a/src/config/config-loader.ts b/src/config/config-loader.ts index 4056fbe..c48ee61 100644 --- a/src/config/config-loader.ts +++ b/src/config/config-loader.ts @@ -27,7 +27,7 @@ export function loadConfig(configPath?: string): AppConfig | null { DEFAULT_CONFIG.documentation ?? { enabled: true, patterns: [] }; const staticAnalysisDefaults: NonNullable = DEFAULT_CONFIG.staticAnalysis ?? { enabled: true, - autoInstall: true, + autoInstall: false, autoInstallSystemDeps: false, excludedPaths: [...STATIC_ANALYSIS_DEFAULT_EXCLUDED_PATHS], semgrep: { diff --git a/src/config/default-config.ts b/src/config/default-config.ts index ebcaf70..882ba7f 100644 --- a/src/config/default-config.ts +++ b/src/config/default-config.ts @@ -135,7 +135,7 @@ export const DEFAULT_CONFIG: AppConfig = { }, staticAnalysis: { enabled: true, - autoInstall: true, + autoInstall: false, autoInstallSystemDeps: false, excludedPaths: [...STATIC_ANALYSIS_DEFAULT_EXCLUDED_PATHS], semgrep: { diff --git a/src/orchestrator/commit-evaluation-orchestrator.ts b/src/orchestrator/commit-evaluation-orchestrator.ts index 2f16700..eac9dd3 100644 --- a/src/orchestrator/commit-evaluation-orchestrator.ts +++ b/src/orchestrator/commit-evaluation-orchestrator.ts @@ -10,6 +10,10 @@ import { 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( @@ -144,6 +148,30 @@ export class CommitEvaluationOrchestrator { } ); 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, @@ -152,6 +180,10 @@ export class CommitEvaluationOrchestrator { }, }); } + } else { + LoggerService.info('Running commit evaluation without static analysis (disabled in config).', { + category: STATIC_ANALYSIS_LOG_CATEGORY, + }); } // Initialize graph state diff --git a/src/services/static-analysis/__tests__/static-analysis.service.test.ts b/src/services/static-analysis/__tests__/static-analysis.service.test.ts index 283bc48..c45c338 100644 --- a/src/services/static-analysis/__tests__/static-analysis.service.test.ts +++ b/src/services/static-analysis/__tests__/static-analysis.service.test.ts @@ -152,6 +152,50 @@ describe('StaticAnalysisService', () => { 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'], @@ -167,6 +211,52 @@ describe('StaticAnalysisService', () => { 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: { diff --git a/src/services/static-analysis/static-analysis.service.ts b/src/services/static-analysis/static-analysis.service.ts index 4ae55ec..ab16458 100644 --- a/src/services/static-analysis/static-analysis.service.ts +++ b/src/services/static-analysis/static-analysis.service.ts @@ -27,6 +27,10 @@ const ALWAYS_EXCLUDED_TEST_PATTERNS = [ '**/*_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'; @@ -89,10 +93,13 @@ export class StaticAnalysisService { const availabilityStart = Date.now(); try { - availability = await ToolInstaller.ensureAvailable( - config.autoInstall, - config.autoInstallSystemDeps - ); + 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)}` @@ -112,6 +119,13 @@ export class StaticAnalysisService { 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, From 5059994b9ea9cacd984a5c7d42728c772d0567fa Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Thu, 26 Feb 2026 15:09:23 +0100 Subject: [PATCH 22/24] feat(static-analysis): integrate analysis findings into agent prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add finding-formatter module that routes static analysis results to agents based on category expertise. Each agent receives a filtered, formatted view of findings relevant to their role: - Category routing: security→architect, quality/style/bug→reviewer, etc. - Primary agents see all severities; secondary agents see error+warning only - Round 1 gets full findings; Round 2+ gets condensed error-only reference - Safety cap (200 findings) prevents prompt bloat in pathological cases Wire summary through LangGraph state → AgentContext → agent prompts. --- src/agents/agent.interface.ts | 1 + .../implementations/business-analyst-agent.ts | 13 + .../implementations/developer-author-agent.ts | 13 + .../developer-reviewer-agent.ts | 13 + src/agents/implementations/sdet-agent.ts | 13 + .../implementations/senior-architect-agent.ts | 13 + src/orchestrator/commit-evaluation-graph.ts | 2 + .../commit-evaluation-orchestrator.ts | 1 + .../__tests__/finding-formatter.test.ts | 434 ++++++++++++++++++ .../static-analysis/finding-formatter.ts | 268 +++++++++++ src/services/static-analysis/index.ts | 1 + 11 files changed, 772 insertions(+) create mode 100644 src/services/static-analysis/__tests__/finding-formatter.test.ts create mode 100644 src/services/static-analysis/finding-formatter.ts 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/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 eac9dd3..8d3777a 100644 --- a/src/orchestrator/commit-evaluation-orchestrator.ts +++ b/src/orchestrator/commit-evaluation-orchestrator.ts @@ -193,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 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/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 index 35a4999..c294d16 100644 --- a/src/services/static-analysis/index.ts +++ b/src/services/static-analysis/index.ts @@ -6,3 +6,4 @@ export * from './semgrep-runner'; export * from './qlty-runner'; export * from './qlty-smells-runner'; export * from './static-analysis.service'; +export * from './finding-formatter'; From 587bd8f46f08c832d0c3ababd8f2ae0a58c3a5a3 Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Thu, 26 Feb 2026 16:01:03 +0100 Subject: [PATCH 23/24] docs(static-analysis): document the static analysis pipeline across all docs Add production documentation for the static analysis feature across 7 files: - ARCHITECTURE.md: full pipeline section with ASCII diagram, runners table, unified finding type, changed-lines scoping, category routing, agent injection - ADVANCED_FEATURES.md: user-facing section covering routing, risk levels, round behavior, graceful degradation, and prompt format - AGENTS.md: per-agent static analysis categories received blocks - CONFIGURATION.md: tool installation subsection with caching and re-install - CHANGELOG.md: v0.0.6 entry with features and fixes - INDEX.md: navigation entries, feature coverage row, updated stats - README.md: feature bullet and expanded quick start --- README.md | 3 +- docs/ADVANCED_FEATURES.md | 96 +++++++++++++++++++ docs/AGENTS.md | 25 +++++ docs/ARCHITECTURE.md | 192 +++++++++++++++++++++++++++++++++++++- docs/CHANGELOG.md | 22 +++++ docs/CONFIGURATION.md | 14 +++ docs/INDEX.md | 19 +++- 7 files changed, 362 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4fcb597..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,7 +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 +- **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. 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 28e2c88..989f7be 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -393,6 +393,20 @@ 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 diff --git a/docs/INDEX.md b/docs/INDEX.md index 9771034..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,14 @@ 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) @@ -160,9 +162,9 @@ 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 | @@ -213,6 +215,12 @@ 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 @@ -286,6 +294,7 @@ START HERE | Examples | | | ✓ | | ✓ | ✓ | | | | | Troubleshooting | | | | | | | | | ✓ | | Scoring | ✓ | ✓ | | | | | | | | +| Static Analysis | | | | ✓ | | | ✓ | ✓ | | | Evaluation | ✓ | ✓ | ✓ | | ✓ | ✓ | ✓ | ✓ | ✓ | ## Learning Paths From 44a8bb0884da43022382a68c1e601b474ab0ff12 Mon Sep 17 00:00:00 2001 From: Amar Zhuri Date: Fri, 27 Feb 2026 14:22:00 +0100 Subject: [PATCH 24/24] feat(static-analysis): expand default exclusions and make user excludedPaths additive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ~80 new default exclusion patterns covering vendor dirs, IDE configs, generated code, minified/bundled files, binary assets, build outputs, language caches, and CI/CD configs - Fix config loader to merge user excludedPaths on top of defaults instead of silently replacing them - Remove excludedPaths from config --init output so the config file stays clean — defaults live in code, users only add their own patterns - Add tests for merge, deduplication, and preservation of defaults --- cli/commands/config.command.ts | 8 +- .../config-loader.validation.test.ts | 88 ++++++++++++ src/config/config-loader.ts | 7 + src/config/default-config.ts | 126 +++++++++++++++++- 4 files changed, 220 insertions(+), 9 deletions(-) diff --git a/cli/commands/config.command.ts b/cli/commands/config.command.ts index 40af088..25a780a 100644 --- a/cli/commands/config.command.ts +++ b/cli/commands/config.command.ts @@ -3,7 +3,6 @@ import * as path from 'path'; import inquirer from 'inquirer'; import chalk from 'chalk'; import { ToolInstaller } from '../../src/services/static-analysis/tool-installer'; -import { STATIC_ANALYSIS_DEFAULT_EXCLUDED_PATHS } from '../../src/config/default-config'; const CONFIG_FILE = '.codewave.config.json'; @@ -55,7 +54,6 @@ const DEFAULT_CONFIG = { enabled: true, autoInstall: false, autoInstallSystemDeps: false, - excludedPaths: [...STATIC_ANALYSIS_DEFAULT_EXCLUDED_PATHS], semgrep: { enabled: true, rules: ['auto'], @@ -162,12 +160,10 @@ async function initializeConfig(): Promise { config.tracing = { ...config.tracing, ...existingConfig.tracing }; } if (existingConfig.staticAnalysis) { + const { excludedPaths: _drop, ...existingStaticAnalysis } = existingConfig.staticAnalysis; config.staticAnalysis = { ...config.staticAnalysis, - ...existingConfig.staticAnalysis, - excludedPaths: Array.isArray(existingConfig.staticAnalysis.excludedPaths) - ? [...existingConfig.staticAnalysis.excludedPaths] - : [...config.staticAnalysis.excludedPaths], + ...existingStaticAnalysis, semgrep: { ...config.staticAnalysis.semgrep, ...(existingConfig.staticAnalysis.semgrep || {}), diff --git a/src/config/__tests__/config-loader.validation.test.ts b/src/config/__tests__/config-loader.validation.test.ts index 4511d8d..0cad6c6 100644 --- a/src/config/__tests__/config-loader.validation.test.ts +++ b/src/config/__tests__/config-loader.validation.test.ts @@ -3,6 +3,7 @@ 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', () => { @@ -98,4 +99,91 @@ describe('loadConfig validation behavior', () => { /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/config-loader.ts b/src/config/config-loader.ts index c48ee61..0003e4a 100644 --- a/src/config/config-loader.ts +++ b/src/config/config-loader.ts @@ -62,6 +62,13 @@ export function loadConfig(configPath?: string): AppConfig | null { ? { ...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 || {}), diff --git a/src/config/default-config.ts b/src/config/default-config.ts index 882ba7f..8aed8c1 100644 --- a/src/config/default-config.ts +++ b/src/config/default-config.ts @@ -4,19 +4,59 @@ 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/**', - 'coverage/**', + '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/**', @@ -24,15 +64,95 @@ export const STATIC_ANALYSIS_DEFAULT_EXCLUDED_PATHS = [ '**/*.spec.*', '**/*_test.*', '**/*_spec.*', - // Documentation files are low-signal for static analyzers and expensive at scale. + + // --- Documentation (low-signal for static analyzers) --- 'docs/**', + 'doc/**', '**/*.md', '**/*.markdown', '**/*.mdx', '**/*.rst', '**/*.adoc', '**/*.asciidoc', - // Lockfiles and generated dependency manifests across ecosystems. + + // --- 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',