feat(lint): add lint command with AI-powered fixes
Introduce new `grokkit lint` command for automatic language detection, linting, and AI-suggested fixes. Supports 9 languages including Go, Python, JavaScript, TypeScript, Rust, Ruby, Java, C/C++, and Shell. - Add cmd/lint.go for command implementation - Create internal/linter package with detection and execution logic - Update README.md with usage examples and workflows - Enhance docs/ARCHITECTURE.md and docs/TROUBLESHOOTING.md - Add comprehensive tests for linter functionality
This commit is contained in:
parent
cbd779c882
commit
9f1309ba1a
54
README.md
54
README.md
@ -145,6 +145,40 @@ Multi-file agent for complex refactoring (experimental).
|
||||
grokkit agent "refactor authentication to use JWT"
|
||||
```
|
||||
|
||||
### 🔧 `grokkit lint FILE`
|
||||
Automatically detect language, run linter, and apply AI-suggested fixes.
|
||||
|
||||
```bash
|
||||
# Just check for issues (no fixes)
|
||||
grokkit lint main.go --dry-run
|
||||
|
||||
# Interactive fix (preview + confirmation)
|
||||
grokkit lint app.py
|
||||
|
||||
# Auto-fix without confirmation
|
||||
grokkit lint server.js --auto-fix
|
||||
|
||||
# Use specific model
|
||||
grokkit lint script.rb -m grok-4
|
||||
```
|
||||
|
||||
**Supported languages:**
|
||||
- **Go** (golangci-lint, go vet)
|
||||
- **Python** (pylint, flake8, ruff)
|
||||
- **JavaScript/JSX** (eslint)
|
||||
- **TypeScript/TSX** (eslint, tsc)
|
||||
- **Rust** (clippy)
|
||||
- **Ruby** (rubocop)
|
||||
- **Java** (checkstyle)
|
||||
- **C/C++** (clang-tidy)
|
||||
- **Shell** (shellcheck)
|
||||
|
||||
**Safety features:**
|
||||
- Creates `.bak` backup before changes
|
||||
- Shows preview of fixes
|
||||
- Verifies fixes by re-running linter
|
||||
- Requires confirmation (unless `--auto-fix`)
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
@ -252,6 +286,25 @@ done
|
||||
grokkit review
|
||||
```
|
||||
|
||||
### Code Quality Workflow
|
||||
|
||||
```bash
|
||||
# 1. Check a file for linting issues
|
||||
grokkit lint app.py --dry-run
|
||||
|
||||
# 2. Apply AI-suggested fixes with preview
|
||||
grokkit lint app.py
|
||||
|
||||
# 3. Auto-fix multiple files
|
||||
for file in src/*.js; do
|
||||
grokkit lint "$file" --auto-fix
|
||||
done
|
||||
|
||||
# 4. Review and commit
|
||||
grokkit review
|
||||
grokkit commit
|
||||
```
|
||||
|
||||
## Shell Completions
|
||||
|
||||
Generate shell completions for faster command entry:
|
||||
@ -305,6 +358,7 @@ grokkit review -v
|
||||
- ✅ **Shell completions** - Bash, Zsh, Fish, PowerShell
|
||||
- ✅ **Safe file editing** - Automatic backups, preview, confirmation
|
||||
- ✅ **Git workflow integration** - Commit messages, reviews, PR descriptions
|
||||
- ✅ **Multi-language linting** - 9 languages supported with AI-powered fixes
|
||||
|
||||
### Quality & Testing
|
||||
- ✅ **Test coverage 72%** - Comprehensive unit tests
|
||||
|
||||
231
cmd/lint.go
Normal file
231
cmd/lint.go
Normal file
@ -0,0 +1,231 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"gmgauthier.com/grokkit/config"
|
||||
"gmgauthier.com/grokkit/internal/grok"
|
||||
"gmgauthier.com/grokkit/internal/linter"
|
||||
"gmgauthier.com/grokkit/internal/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
dryRun bool
|
||||
autoFix bool
|
||||
)
|
||||
|
||||
var lintCmd = &cobra.Command{
|
||||
Use: "lint <file>",
|
||||
Short: "Lint a file and optionally apply AI-suggested fixes",
|
||||
Long: `Automatically detects the programming language of a file, runs the appropriate
|
||||
linter, and optionally uses Grok AI to apply suggested fixes.
|
||||
|
||||
The command will:
|
||||
1. Detect the file's programming language
|
||||
2. Check for available linters
|
||||
3. Run the linter and report issues
|
||||
4. (Optional) Use Grok AI to generate and apply fixes
|
||||
|
||||
Safety features:
|
||||
- Creates backup before modifying files
|
||||
- Shows preview of changes before applying
|
||||
- Requires confirmation unless --auto-fix is used`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
Run: runLint,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(lintCmd)
|
||||
lintCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show linting issues without fixing")
|
||||
lintCmd.Flags().BoolVar(&autoFix, "auto-fix", false, "Apply fixes without confirmation")
|
||||
}
|
||||
|
||||
func runLint(cmd *cobra.Command, args []string) {
|
||||
filePath := args[0]
|
||||
|
||||
logger.Info("starting lint operation", "file", filePath, "dry_run", dryRun, "auto_fix", autoFix)
|
||||
|
||||
// Check file exists
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
logger.Error("file not found", "file", filePath, "error", err)
|
||||
color.Red("❌ File not found: %s", filePath)
|
||||
return
|
||||
}
|
||||
|
||||
// Get absolute path for better logging
|
||||
absPath, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
logger.Warn("could not get absolute path", "file", filePath, "error", err)
|
||||
absPath = filePath
|
||||
}
|
||||
|
||||
// Run linter
|
||||
color.Cyan("🔍 Detecting language and running linter...")
|
||||
result, err := linter.LintFile(absPath)
|
||||
|
||||
if err != nil {
|
||||
// Check if it's because no linter is available
|
||||
if result != nil && !result.LinterExists {
|
||||
logger.Warn("no linter available", "file", absPath, "language", result.Language)
|
||||
color.Yellow("⚠️ %s", result.Output)
|
||||
return
|
||||
}
|
||||
logger.Error("linting failed", "file", absPath, "error", err)
|
||||
color.Red("❌ Failed to lint file: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Display results
|
||||
color.Cyan("📋 Language: %s", result.Language)
|
||||
color.Cyan("🔧 Linter: %s", result.LinterUsed)
|
||||
fmt.Println()
|
||||
|
||||
if !result.HasIssues {
|
||||
logger.Info("no linting issues found", "file", absPath, "linter", result.LinterUsed)
|
||||
color.Green("✅ No issues found!")
|
||||
return
|
||||
}
|
||||
|
||||
// Show linter output
|
||||
logger.Info("linting issues found", "file", absPath, "exit_code", result.ExitCode)
|
||||
color.Yellow("⚠️ Issues found:")
|
||||
fmt.Println(strings.TrimSpace(result.Output))
|
||||
fmt.Println()
|
||||
|
||||
// Stop here if dry-run
|
||||
if dryRun {
|
||||
logger.Info("dry-run mode, skipping fixes", "file", absPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Read original file content
|
||||
originalContent, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
logger.Error("failed to read file", "file", absPath, "error", err)
|
||||
color.Red("❌ Failed to read file: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Use Grok AI to generate fixes
|
||||
color.Cyan("🤖 Asking Grok AI for fixes...")
|
||||
logger.Info("requesting AI fixes", "file", absPath, "original_size", len(originalContent))
|
||||
|
||||
modelFlag, _ := cmd.Flags().GetString("model")
|
||||
model := config.GetModel(modelFlag)
|
||||
|
||||
client := grok.NewClient()
|
||||
messages := buildLintFixMessages(result, string(originalContent))
|
||||
response := client.StreamSilent(messages, model)
|
||||
|
||||
if response == "" {
|
||||
logger.Error("AI returned empty response", "file", absPath)
|
||||
color.Red("❌ Failed to get AI response")
|
||||
return
|
||||
}
|
||||
|
||||
// Clean the response
|
||||
fixedCode := grok.CleanCodeResponse(response)
|
||||
logger.Info("received AI fixes", "file", absPath, "fixed_size", len(fixedCode))
|
||||
|
||||
// Create backup
|
||||
backupPath := absPath + ".bak"
|
||||
if err := os.WriteFile(backupPath, originalContent, 0644); err != nil {
|
||||
logger.Error("failed to create backup", "file", absPath, "error", err)
|
||||
color.Red("❌ Failed to create backup: %v", err)
|
||||
return
|
||||
}
|
||||
logger.Info("backup created", "backup", backupPath)
|
||||
color.Green("💾 Backup created: %s", backupPath)
|
||||
|
||||
// Show preview if not auto-fix
|
||||
if !autoFix {
|
||||
fmt.Println()
|
||||
color.Cyan("📝 Preview of fixes:")
|
||||
fmt.Println(strings.Repeat("-", 80))
|
||||
// Show first 50 lines of fixed code
|
||||
lines := strings.Split(fixedCode, "\n")
|
||||
previewLines := 50
|
||||
if len(lines) < previewLines {
|
||||
previewLines = len(lines)
|
||||
}
|
||||
for i := 0; i < previewLines; i++ {
|
||||
fmt.Println(lines[i])
|
||||
}
|
||||
if len(lines) > previewLines {
|
||||
fmt.Printf("... (%d more lines)\n", len(lines)-previewLines)
|
||||
}
|
||||
fmt.Println(strings.Repeat("-", 80))
|
||||
fmt.Println()
|
||||
|
||||
// Ask for confirmation
|
||||
color.Yellow("Apply these fixes? (y/N): ")
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
response = strings.ToLower(strings.TrimSpace(response))
|
||||
|
||||
if response != "y" && response != "yes" {
|
||||
logger.Info("user cancelled fix application", "file", absPath)
|
||||
color.Yellow("❌ Cancelled. Backup preserved at: %s", backupPath)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Apply fixes
|
||||
if err := os.WriteFile(absPath, []byte(fixedCode), 0644); err != nil {
|
||||
logger.Error("failed to write fixed file", "file", absPath, "error", err)
|
||||
color.Red("❌ Failed to write file: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info("fixes applied successfully", "file", absPath)
|
||||
color.Green("✅ Fixes applied successfully!")
|
||||
color.Cyan("💾 Original saved to: %s", backupPath)
|
||||
|
||||
// Optionally run linter again to verify
|
||||
color.Cyan("\n🔍 Re-running linter to verify fixes...")
|
||||
verifyResult, err := linter.LintFile(absPath)
|
||||
if err == nil && !verifyResult.HasIssues {
|
||||
logger.Info("verification successful, no issues remain", "file", absPath)
|
||||
color.Green("✅ Verification passed! No issues remaining.")
|
||||
} else if err == nil && verifyResult.HasIssues {
|
||||
logger.Warn("verification found remaining issues", "file", absPath, "exit_code", verifyResult.ExitCode)
|
||||
color.Yellow("⚠️ Some issues may remain:")
|
||||
fmt.Println(strings.TrimSpace(verifyResult.Output))
|
||||
}
|
||||
}
|
||||
|
||||
func buildLintFixMessages(result *linter.LintResult, originalCode string) []map[string]string {
|
||||
systemPrompt := "You are a code quality expert. Fix linter issues and return only the corrected code with no explanations, markdown, or extra text."
|
||||
|
||||
userPrompt := fmt.Sprintf(`A linter has found issues in the following %s code.
|
||||
|
||||
Linter: %s
|
||||
Issues found:
|
||||
%s
|
||||
|
||||
Original code:
|
||||
%s
|
||||
|
||||
Please fix all the issues identified by the linter. Return ONLY the corrected code without any explanations, markdown formatting, or code fences. The output should be ready to write directly to the file.
|
||||
|
||||
Important:
|
||||
- Fix ALL issues reported by the linter
|
||||
- Preserve all functionality
|
||||
- Maintain the original code style as much as possible
|
||||
- Do NOT include markdown code fences
|
||||
- Do NOT include explanations or comments about the changes`,
|
||||
result.Language,
|
||||
result.LinterUsed,
|
||||
strings.TrimSpace(result.Output),
|
||||
originalCode)
|
||||
|
||||
return []map[string]string{
|
||||
{"role": "system", "content": systemPrompt},
|
||||
{"role": "user", "content": userPrompt},
|
||||
}
|
||||
}
|
||||
@ -70,6 +70,9 @@ grokkit/
|
||||
│ │ ├── client.go # HTTP client + streaming
|
||||
│ │ ├── interface.go # AIClient interface
|
||||
│ │ └── client_test.go
|
||||
│ ├── linter/ # Multi-language linting
|
||||
│ │ ├── linter.go # Language detection + linter execution
|
||||
│ │ └── linter_test.go
|
||||
│ └── logger/ # Structured logging (slog)
|
||||
│ ├── logger.go # Logger setup + helpers
|
||||
│ └── logger_test.go
|
||||
@ -91,6 +94,7 @@ grokkit/
|
||||
- **`internal/errors/`**: Domain-specific error types
|
||||
- **`internal/git/`**: Git command abstraction
|
||||
- **`internal/grok/`**: API client with streaming support
|
||||
- **`internal/linter/`**: Language detection and linter execution
|
||||
- **`internal/logger/`**: Structured logging facade
|
||||
|
||||
## Core Components
|
||||
@ -204,7 +208,51 @@ func Run(args []string) (string, error) {
|
||||
- Testability via interface
|
||||
- Future: git library instead of exec
|
||||
|
||||
### 5. Logging (`internal/logger/`)
|
||||
### 5. Linter (`internal/linter/`)
|
||||
|
||||
**Responsibility:** Language detection and linter orchestration
|
||||
|
||||
**Pattern:** Strategy pattern (multiple linters per language)
|
||||
|
||||
```go
|
||||
type Language struct {
|
||||
Name string
|
||||
Extensions []string
|
||||
Linters []Linter // Multiple options per language
|
||||
}
|
||||
|
||||
type Linter struct {
|
||||
Name string
|
||||
Command string
|
||||
Args []string
|
||||
InstallInfo string
|
||||
}
|
||||
|
||||
// Auto-detect language and find available linter
|
||||
func LintFile(filePath string) (*LintResult, error) {
|
||||
lang := DetectLanguage(filePath) // By extension
|
||||
linter := FindAvailableLinter(lang) // Check PATH
|
||||
return RunLinter(filePath, linter) // Execute
|
||||
}
|
||||
```
|
||||
|
||||
**Supported languages:**
|
||||
- Go (golangci-lint, go vet)
|
||||
- Python (pylint, flake8, ruff)
|
||||
- JavaScript/TypeScript (eslint, tsc)
|
||||
- Rust (clippy)
|
||||
- Ruby (rubocop)
|
||||
- Java (checkstyle)
|
||||
- C/C++ (clang-tidy)
|
||||
- Shell (shellcheck)
|
||||
|
||||
**Design features:**
|
||||
- Fallback linters (tries multiple options)
|
||||
- Tool availability checking
|
||||
- Helpful install instructions
|
||||
- Exit code interpretation
|
||||
|
||||
### 6. Logging (`internal/logger/`)
|
||||
|
||||
**Responsibility:** Structured logging with context
|
||||
|
||||
@ -321,6 +369,41 @@ exec.Command("git", "commit") # Execute commit
|
||||
├─→ logger.Info() # Log result
|
||||
```
|
||||
|
||||
### Lint Command Flow
|
||||
|
||||
```
|
||||
User: grokkit lint file.py [--dry-run] [--auto-fix]
|
||||
↓
|
||||
lintCmd.Run()
|
||||
├─→ Validate file exists
|
||||
↓
|
||||
linter.LintFile()
|
||||
├─→ DetectLanguage() # By file extension
|
||||
├─→ FindAvailableLinter() # Check PATH
|
||||
├─→ RunLinter() # Execute linter
|
||||
↓
|
||||
Display linter output
|
||||
↓
|
||||
(if --dry-run) → Exit
|
||||
↓
|
||||
(if issues found) → Request AI fixes
|
||||
↓
|
||||
grok.Client.StreamSilent() # Get fixed code
|
||||
├─→ Build prompt with linter output + original code
|
||||
├─→ Stream API response
|
||||
↓
|
||||
grok.CleanCodeResponse() # Remove markdown
|
||||
↓
|
||||
Create backup (.bak)
|
||||
↓
|
||||
(if not --auto-fix) → Show preview + prompt
|
||||
↓
|
||||
Write fixed file
|
||||
↓
|
||||
linter.LintFile() # Verify fixes
|
||||
├─→ Display verification result
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
@ -510,6 +510,126 @@ mv ~/.config/grokkit/chat_history.new.json \
|
||||
~/.config/grokkit/chat_history.json
|
||||
```
|
||||
|
||||
## 7. Linter Issues
|
||||
|
||||
### No linter found for language
|
||||
|
||||
**Symptom:**
|
||||
```
|
||||
⚠️ no linter found for Go
|
||||
|
||||
Install one of:
|
||||
- golangci-lint: https://golangci-lint.run/usage/install/
|
||||
- go vet: Built-in with Go
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Install appropriate linter for your language
|
||||
|
||||
# Go
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||
# Or use go vet (built-in)
|
||||
|
||||
# Python
|
||||
pip install pylint flake8 ruff
|
||||
|
||||
# JavaScript/TypeScript
|
||||
npm install -g eslint
|
||||
|
||||
# Rust
|
||||
rustup component add clippy
|
||||
|
||||
# Ruby
|
||||
gem install rubocop
|
||||
|
||||
# Shell
|
||||
# Ubuntu/Debian
|
||||
apt install shellcheck
|
||||
# macOS
|
||||
brew install shellcheck
|
||||
|
||||
# Verify installation
|
||||
which golangci-lint # or your linter command
|
||||
```
|
||||
|
||||
### Linter found issues but AI fixes failed
|
||||
|
||||
**Symptom:**
|
||||
Linter reports issues but AI returns empty response or invalid code.
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# 1. Try with a different model
|
||||
grokkit lint file.py -m grok-4
|
||||
|
||||
# 2. Check the linter output is clear
|
||||
grokkit lint file.py --dry-run
|
||||
|
||||
# 3. Try manual fix first, then use edit command
|
||||
grokkit edit file.py "fix the issues reported by linter"
|
||||
|
||||
# 4. Enable debug logging to see API interaction
|
||||
grokkit lint file.py --debug
|
||||
```
|
||||
|
||||
### Linter verification still shows issues
|
||||
|
||||
**Symptom:**
|
||||
After applying AI fixes, re-running the linter still reports issues.
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Some issues may require manual intervention
|
||||
# Check the backup file to compare
|
||||
diff file.py file.py.bak
|
||||
|
||||
# Apply fixes iteratively
|
||||
grokkit lint file.py # Apply fixes
|
||||
grokkit lint file.py # Run again on remaining issues
|
||||
|
||||
# For complex issues, use edit with specific instructions
|
||||
grokkit edit file.py "fix the remaining pylint warnings about docstrings"
|
||||
```
|
||||
|
||||
### File extension not recognized
|
||||
|
||||
**Symptom:**
|
||||
```
|
||||
unsupported file type: .xyz
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
Currently supported extensions:
|
||||
- **Go:** `.go`
|
||||
- **Python:** `.py`
|
||||
- **JavaScript:** `.js`, `.jsx`
|
||||
- **TypeScript:** `.ts`, `.tsx`
|
||||
- **Rust:** `.rs`
|
||||
- **Ruby:** `.rb`
|
||||
- **Java:** `.java`
|
||||
- **C/C++:** `.c`, `.cpp`, `.cc`, `.cxx`, `.h`, `.hpp`
|
||||
- **Shell:** `.sh`, `.bash`
|
||||
|
||||
For unsupported languages, use the `edit` command instead:
|
||||
```bash
|
||||
grokkit edit myfile.xyz "review and fix code quality issues"
|
||||
```
|
||||
|
||||
### Wrong linter used
|
||||
|
||||
**Symptom:**
|
||||
Tool uses go vet but you want golangci-lint, or uses flake8 but you want ruff.
|
||||
|
||||
**Explanation:**
|
||||
Grokkit tries linters in the order defined and uses the first available one. To use a specific linter, ensure it's installed and others are not in PATH, or contribute to the project to add linter preference configuration.
|
||||
|
||||
**Current linter priority:**
|
||||
- **Go:** golangci-lint → go vet
|
||||
- **Python:** pylint → flake8 → ruff
|
||||
- **JavaScript:** eslint
|
||||
- **TypeScript:** eslint → tsc
|
||||
|
||||
## Getting Help
|
||||
|
||||
If none of these solutions work:
|
||||
|
||||
310
internal/linter/linter.go
Normal file
310
internal/linter/linter.go
Normal file
@ -0,0 +1,310 @@
|
||||
package linter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gmgauthier.com/grokkit/internal/logger"
|
||||
)
|
||||
|
||||
// Language represents a programming language
|
||||
type Language struct {
|
||||
Name string
|
||||
Extensions []string
|
||||
Linters []Linter
|
||||
}
|
||||
|
||||
// Linter represents a linting tool
|
||||
type Linter struct {
|
||||
Name string
|
||||
Command string
|
||||
Args []string
|
||||
InstallInfo string
|
||||
}
|
||||
|
||||
// LintResult contains linting results
|
||||
type LintResult struct {
|
||||
Language string
|
||||
LinterUsed string
|
||||
Output string
|
||||
ExitCode int
|
||||
HasIssues bool
|
||||
LinterExists bool
|
||||
}
|
||||
|
||||
// Supported languages and their linters
|
||||
var languages = []Language{
|
||||
{
|
||||
Name: "Go",
|
||||
Extensions: []string{".go"},
|
||||
Linters: []Linter{
|
||||
{
|
||||
Name: "golangci-lint",
|
||||
Command: "golangci-lint",
|
||||
Args: []string{"run"},
|
||||
InstallInfo: "https://golangci-lint.run/usage/install/",
|
||||
},
|
||||
{
|
||||
Name: "go vet",
|
||||
Command: "go",
|
||||
Args: []string{"vet"},
|
||||
InstallInfo: "Built-in with Go",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Python",
|
||||
Extensions: []string{".py"},
|
||||
Linters: []Linter{
|
||||
{
|
||||
Name: "pylint",
|
||||
Command: "pylint",
|
||||
Args: []string{},
|
||||
InstallInfo: "pip install pylint",
|
||||
},
|
||||
{
|
||||
Name: "flake8",
|
||||
Command: "flake8",
|
||||
Args: []string{},
|
||||
InstallInfo: "pip install flake8",
|
||||
},
|
||||
{
|
||||
Name: "ruff",
|
||||
Command: "ruff",
|
||||
Args: []string{"check"},
|
||||
InstallInfo: "pip install ruff",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "JavaScript",
|
||||
Extensions: []string{".js", ".jsx"},
|
||||
Linters: []Linter{
|
||||
{
|
||||
Name: "eslint",
|
||||
Command: "eslint",
|
||||
Args: []string{},
|
||||
InstallInfo: "npm install -g eslint",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "TypeScript",
|
||||
Extensions: []string{".ts", ".tsx"},
|
||||
Linters: []Linter{
|
||||
{
|
||||
Name: "eslint",
|
||||
Command: "eslint",
|
||||
Args: []string{},
|
||||
InstallInfo: "npm install -g eslint @typescript-eslint/parser",
|
||||
},
|
||||
{
|
||||
Name: "tsc",
|
||||
Command: "tsc",
|
||||
Args: []string{"--noEmit"},
|
||||
InstallInfo: "npm install -g typescript",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Rust",
|
||||
Extensions: []string{".rs"},
|
||||
Linters: []Linter{
|
||||
{
|
||||
Name: "clippy",
|
||||
Command: "cargo",
|
||||
Args: []string{"clippy", "--"},
|
||||
InstallInfo: "rustup component add clippy",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Ruby",
|
||||
Extensions: []string{".rb"},
|
||||
Linters: []Linter{
|
||||
{
|
||||
Name: "rubocop",
|
||||
Command: "rubocop",
|
||||
Args: []string{},
|
||||
InstallInfo: "gem install rubocop",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Java",
|
||||
Extensions: []string{".java"},
|
||||
Linters: []Linter{
|
||||
{
|
||||
Name: "checkstyle",
|
||||
Command: "checkstyle",
|
||||
Args: []string{"-c", "/google_checks.xml"},
|
||||
InstallInfo: "https://checkstyle.org/",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "C/C++",
|
||||
Extensions: []string{".c", ".cpp", ".cc", ".cxx", ".h", ".hpp"},
|
||||
Linters: []Linter{
|
||||
{
|
||||
Name: "clang-tidy",
|
||||
Command: "clang-tidy",
|
||||
Args: []string{},
|
||||
InstallInfo: "apt install clang-tidy or brew install llvm",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Shell",
|
||||
Extensions: []string{".sh", ".bash"},
|
||||
Linters: []Linter{
|
||||
{
|
||||
Name: "shellcheck",
|
||||
Command: "shellcheck",
|
||||
Args: []string{},
|
||||
InstallInfo: "apt install shellcheck or brew install shellcheck",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// DetectLanguage detects the programming language based on file extension
|
||||
func DetectLanguage(filePath string) (*Language, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
|
||||
logger.Debug("detecting language", "file", filePath, "extension", ext)
|
||||
|
||||
for i := range languages {
|
||||
for _, langExt := range languages[i].Extensions {
|
||||
if ext == langExt {
|
||||
logger.Info("language detected", "file", filePath, "language", languages[i].Name)
|
||||
return &languages[i], nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported file type: %s", ext)
|
||||
}
|
||||
|
||||
// CheckLinterAvailable checks if a linter command is available
|
||||
func CheckLinterAvailable(linter Linter) bool {
|
||||
_, err := exec.LookPath(linter.Command)
|
||||
available := err == nil
|
||||
logger.Debug("checking linter availability",
|
||||
"linter", linter.Name,
|
||||
"command", linter.Command,
|
||||
"available", available)
|
||||
return available
|
||||
}
|
||||
|
||||
// FindAvailableLinter finds the first available linter for a language
|
||||
func FindAvailableLinter(lang *Language) (*Linter, error) {
|
||||
logger.Info("searching for available linter", "language", lang.Name, "options", len(lang.Linters))
|
||||
|
||||
for i := range lang.Linters {
|
||||
if CheckLinterAvailable(lang.Linters[i]) {
|
||||
logger.Info("found available linter",
|
||||
"language", lang.Name,
|
||||
"linter", lang.Linters[i].Name)
|
||||
return &lang.Linters[i], nil
|
||||
}
|
||||
}
|
||||
|
||||
// Build install instructions
|
||||
var installOptions []string
|
||||
for _, linter := range lang.Linters {
|
||||
installOptions = append(installOptions,
|
||||
fmt.Sprintf(" - %s: %s", linter.Name, linter.InstallInfo))
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no linter found for %s\n\nInstall one of:\n%s",
|
||||
lang.Name, strings.Join(installOptions, "\n"))
|
||||
}
|
||||
|
||||
// RunLinter executes the linter on the specified file
|
||||
func RunLinter(filePath string, linter Linter) (*LintResult, error) {
|
||||
logger.Info("running linter",
|
||||
"file", filePath,
|
||||
"linter", linter.Name,
|
||||
"command", linter.Command)
|
||||
|
||||
// Build command arguments
|
||||
args := append(linter.Args, filePath)
|
||||
|
||||
// Execute linter
|
||||
cmd := exec.Command(linter.Command, args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
result := &LintResult{
|
||||
LinterUsed: linter.Name,
|
||||
Output: string(output),
|
||||
LinterExists: true,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
result.ExitCode = exitErr.ExitCode()
|
||||
result.HasIssues = true
|
||||
logger.Info("linter found issues",
|
||||
"linter", linter.Name,
|
||||
"exit_code", result.ExitCode,
|
||||
"output_length", len(output))
|
||||
} else {
|
||||
logger.Error("linter execution failed",
|
||||
"linter", linter.Name,
|
||||
"error", err)
|
||||
return nil, fmt.Errorf("failed to execute %s: %w", linter.Name, err)
|
||||
}
|
||||
} else {
|
||||
result.ExitCode = 0
|
||||
result.HasIssues = false
|
||||
logger.Info("linter passed with no issues", "linter", linter.Name)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// LintFile detects language and runs appropriate linter
|
||||
func LintFile(filePath string) (*LintResult, error) {
|
||||
// Check file exists
|
||||
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("file not found: %s", filePath)
|
||||
}
|
||||
|
||||
// Detect language
|
||||
lang, err := DetectLanguage(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find available linter
|
||||
linter, err := FindAvailableLinter(lang)
|
||||
if err != nil {
|
||||
return &LintResult{
|
||||
Language: lang.Name,
|
||||
LinterExists: false,
|
||||
Output: err.Error(),
|
||||
}, err
|
||||
}
|
||||
|
||||
// Run linter
|
||||
result, err := RunLinter(filePath, *linter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result.Language = lang.Name
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetSupportedLanguages returns a list of all supported languages
|
||||
func GetSupportedLanguages() []string {
|
||||
var langs []string
|
||||
for _, lang := range languages {
|
||||
langs = append(langs, fmt.Sprintf("%s (%s)", lang.Name, strings.Join(lang.Extensions, ", ")))
|
||||
}
|
||||
return langs
|
||||
}
|
||||
334
internal/linter/linter_test.go
Normal file
334
internal/linter/linter_test.go
Normal file
@ -0,0 +1,334 @@
|
||||
package linter
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDetectLanguage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filePath string
|
||||
wantLang string
|
||||
wantErr bool
|
||||
}{
|
||||
{"Go file", "main.go", "Go", false},
|
||||
{"Python file", "script.py", "Python", false},
|
||||
{"JavaScript file", "app.js", "JavaScript", false},
|
||||
{"JSX file", "component.jsx", "JavaScript", false},
|
||||
{"TypeScript file", "app.ts", "TypeScript", false},
|
||||
{"TSX file", "component.tsx", "TypeScript", false},
|
||||
{"Rust file", "main.rs", "Rust", false},
|
||||
{"Ruby file", "script.rb", "Ruby", false},
|
||||
{"Java file", "Main.java", "Java", false},
|
||||
{"C file", "program.c", "C/C++", false},
|
||||
{"C++ file", "program.cpp", "C/C++", false},
|
||||
{"Header file", "header.h", "C/C++", false},
|
||||
{"Shell script", "script.sh", "Shell", false},
|
||||
{"Bash script", "script.bash", "Shell", false},
|
||||
{"Unsupported file", "file.txt", "", true},
|
||||
{"No extension", "Makefile", "", true},
|
||||
{"Case insensitive", "MAIN.GO", "Go", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
lang, err := DetectLanguage(tt.filePath)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("DetectLanguage(%q) expected error, got nil", tt.filePath)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("DetectLanguage(%q) unexpected error: %v", tt.filePath, err)
|
||||
return
|
||||
}
|
||||
if lang.Name != tt.wantLang {
|
||||
t.Errorf("DetectLanguage(%q) = %q, want %q", tt.filePath, lang.Name, tt.wantLang)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckLinterAvailable(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
linter Linter
|
||||
wantMsg string
|
||||
}{
|
||||
{
|
||||
name: "go command should be available",
|
||||
linter: Linter{
|
||||
Name: "go",
|
||||
Command: "go",
|
||||
},
|
||||
wantMsg: "go should be available on system with Go installed",
|
||||
},
|
||||
{
|
||||
name: "nonexistent command",
|
||||
linter: Linter{
|
||||
Name: "nonexistent",
|
||||
Command: "this-command-does-not-exist-12345",
|
||||
},
|
||||
wantMsg: "nonexistent command should not be available",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
available := CheckLinterAvailable(tt.linter)
|
||||
// We can't assert exact true/false since it depends on the system
|
||||
// Just verify the function runs without panic
|
||||
t.Logf("%s: available=%v", tt.wantMsg, available)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAvailableLinter(t *testing.T) {
|
||||
// Test with Go language since 'go' command should be available
|
||||
// (we're running tests with Go installed)
|
||||
t.Run("Go language should find a linter", func(t *testing.T) {
|
||||
goLang := &Language{
|
||||
Name: "Go",
|
||||
Extensions: []string{".go"},
|
||||
Linters: []Linter{
|
||||
{
|
||||
Name: "go vet",
|
||||
Command: "go",
|
||||
Args: []string{"vet"},
|
||||
InstallInfo: "Built-in with Go",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
linter, err := FindAvailableLinter(goLang)
|
||||
if err != nil {
|
||||
t.Errorf("FindAvailableLinter(Go) expected to find 'go', got error: %v", err)
|
||||
}
|
||||
if linter == nil {
|
||||
t.Errorf("FindAvailableLinter(Go) returned nil linter")
|
||||
} else if linter.Command != "go" {
|
||||
t.Errorf("FindAvailableLinter(Go) = %q, want 'go'", linter.Command)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Language with no available linters", func(t *testing.T) {
|
||||
fakeLang := &Language{
|
||||
Name: "FakeLang",
|
||||
Extensions: []string{".fake"},
|
||||
Linters: []Linter{
|
||||
{
|
||||
Name: "fakelint",
|
||||
Command: "this-does-not-exist-12345",
|
||||
InstallInfo: "not installable",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
linter, err := FindAvailableLinter(fakeLang)
|
||||
if err == nil {
|
||||
t.Errorf("FindAvailableLinter(FakeLang) expected error, got linter: %v", linter)
|
||||
}
|
||||
if linter != nil {
|
||||
t.Errorf("FindAvailableLinter(FakeLang) expected nil linter, got: %v", linter)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRunLinter(t *testing.T) {
|
||||
// Create a temporary Go file with a simple error
|
||||
tmpDir := t.TempDir()
|
||||
testFile := filepath.Join(tmpDir, "test.go")
|
||||
|
||||
// Valid Go code
|
||||
validCode := `package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("Hello, World!")
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(testFile, []byte(validCode), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
t.Run("Run go vet on valid file", func(t *testing.T) {
|
||||
linter := Linter{
|
||||
Name: "go vet",
|
||||
Command: "go",
|
||||
Args: []string{"vet"},
|
||||
}
|
||||
|
||||
result, err := RunLinter(testFile, linter)
|
||||
if err != nil {
|
||||
t.Errorf("RunLinter() unexpected error: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("RunLinter() returned nil result")
|
||||
}
|
||||
if !result.LinterExists {
|
||||
t.Errorf("RunLinter() LinterExists = false, want true")
|
||||
}
|
||||
if result.LinterUsed != "go vet" {
|
||||
t.Errorf("RunLinter() LinterUsed = %q, want 'go vet'", result.LinterUsed)
|
||||
}
|
||||
// Note: go vet might find issues or not, so we don't assert HasIssues
|
||||
t.Logf("go vet result: ExitCode=%d, HasIssues=%v, Output=%q",
|
||||
result.ExitCode, result.HasIssues, result.Output)
|
||||
})
|
||||
|
||||
t.Run("Run nonexistent linter", func(t *testing.T) {
|
||||
linter := Linter{
|
||||
Name: "fake",
|
||||
Command: "this-does-not-exist-12345",
|
||||
Args: []string{},
|
||||
}
|
||||
|
||||
result, err := RunLinter(testFile, linter)
|
||||
if err == nil {
|
||||
t.Errorf("RunLinter() expected error for nonexistent command, got result: %v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLintFile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
t.Run("Lint valid Go file", func(t *testing.T) {
|
||||
testFile := filepath.Join(tmpDir, "valid.go")
|
||||
validCode := `package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("Hello, World!")
|
||||
}
|
||||
`
|
||||
if err := os.WriteFile(testFile, []byte(validCode), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
result, err := LintFile(testFile)
|
||||
if err != nil {
|
||||
t.Errorf("LintFile() unexpected error: %v", err)
|
||||
}
|
||||
if result == nil {
|
||||
t.Fatal("LintFile() returned nil result")
|
||||
}
|
||||
if result.Language != "Go" {
|
||||
t.Errorf("LintFile() Language = %q, want 'Go'", result.Language)
|
||||
}
|
||||
if !result.LinterExists {
|
||||
t.Errorf("LintFile() LinterExists = false, want true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Lint nonexistent file", func(t *testing.T) {
|
||||
testFile := filepath.Join(tmpDir, "nonexistent.go")
|
||||
|
||||
result, err := LintFile(testFile)
|
||||
if err == nil {
|
||||
t.Errorf("LintFile() expected error for nonexistent file, got result: %v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Lint unsupported file type", func(t *testing.T) {
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("hello"), 0644); err != nil {
|
||||
t.Fatalf("Failed to create test file: %v", err)
|
||||
}
|
||||
|
||||
result, err := LintFile(testFile)
|
||||
if err == nil {
|
||||
t.Errorf("LintFile() expected error for unsupported file type, got result: %v", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetSupportedLanguages(t *testing.T) {
|
||||
langs := GetSupportedLanguages()
|
||||
|
||||
if len(langs) == 0 {
|
||||
t.Errorf("GetSupportedLanguages() returned empty list")
|
||||
}
|
||||
|
||||
// Check that we have some expected languages
|
||||
expectedLangs := []string{"Go", "Python", "JavaScript", "TypeScript"}
|
||||
for _, expected := range expectedLangs {
|
||||
found := false
|
||||
for _, lang := range langs {
|
||||
if contains(lang, expected) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("GetSupportedLanguages() missing expected language: %s", expected)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify format includes extensions
|
||||
for _, lang := range langs {
|
||||
if !contains(lang, "(") || !contains(lang, ")") {
|
||||
t.Errorf("GetSupportedLanguages() entry missing format 'Name (extensions)': %s", lang)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanguageStructure(t *testing.T) {
|
||||
// Verify the languages slice is properly structured
|
||||
if len(languages) == 0 {
|
||||
t.Fatal("languages slice is empty")
|
||||
}
|
||||
|
||||
for _, lang := range languages {
|
||||
if lang.Name == "" {
|
||||
t.Errorf("Language with empty Name found")
|
||||
}
|
||||
if len(lang.Extensions) == 0 {
|
||||
t.Errorf("Language %q has no extensions", lang.Name)
|
||||
}
|
||||
if len(lang.Linters) == 0 {
|
||||
t.Errorf("Language %q has no linters", lang.Name)
|
||||
}
|
||||
|
||||
// Check extensions start with dot
|
||||
for _, ext := range lang.Extensions {
|
||||
if ext == "" || ext[0] != '.' {
|
||||
t.Errorf("Language %q has invalid extension: %q", lang.Name, ext)
|
||||
}
|
||||
}
|
||||
|
||||
// Check linters have required fields
|
||||
for _, linter := range lang.Linters {
|
||||
if linter.Name == "" {
|
||||
t.Errorf("Language %q has linter with empty Name", lang.Name)
|
||||
}
|
||||
if linter.Command == "" {
|
||||
t.Errorf("Language %q has linter %q with empty Command", lang.Name, linter.Name)
|
||||
}
|
||||
if linter.InstallInfo == "" {
|
||||
t.Errorf("Language %q has linter %q with empty InstallInfo", lang.Name, linter.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > len(substr) &&
|
||||
(s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
|
||||
containsMiddle(s, substr)))
|
||||
}
|
||||
|
||||
func containsMiddle(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user