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:
Greg Gauthier 2026-03-01 13:21:44 +00:00
parent cbd779c882
commit 9f1309ba1a
6 changed files with 1133 additions and 1 deletions

View File

@ -145,6 +145,40 @@ Multi-file agent for complex refactoring (experimental).
grokkit agent "refactor authentication to use JWT" 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 ## Configuration
### Environment Variables ### Environment Variables
@ -252,6 +286,25 @@ done
grokkit review 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 ## Shell Completions
Generate shell completions for faster command entry: Generate shell completions for faster command entry:
@ -305,6 +358,7 @@ grokkit review -v
- ✅ **Shell completions** - Bash, Zsh, Fish, PowerShell - ✅ **Shell completions** - Bash, Zsh, Fish, PowerShell
- ✅ **Safe file editing** - Automatic backups, preview, confirmation - ✅ **Safe file editing** - Automatic backups, preview, confirmation
- ✅ **Git workflow integration** - Commit messages, reviews, PR descriptions - ✅ **Git workflow integration** - Commit messages, reviews, PR descriptions
- ✅ **Multi-language linting** - 9 languages supported with AI-powered fixes
### Quality & Testing ### Quality & Testing
- ✅ **Test coverage 72%** - Comprehensive unit tests - ✅ **Test coverage 72%** - Comprehensive unit tests

231
cmd/lint.go Normal file
View 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},
}
}

View File

@ -70,6 +70,9 @@ grokkit/
│ │ ├── client.go # HTTP client + streaming │ │ ├── client.go # HTTP client + streaming
│ │ ├── interface.go # AIClient interface │ │ ├── interface.go # AIClient interface
│ │ └── client_test.go │ │ └── client_test.go
│ ├── linter/ # Multi-language linting
│ │ ├── linter.go # Language detection + linter execution
│ │ └── linter_test.go
│ └── logger/ # Structured logging (slog) │ └── logger/ # Structured logging (slog)
│ ├── logger.go # Logger setup + helpers │ ├── logger.go # Logger setup + helpers
│ └── logger_test.go │ └── logger_test.go
@ -91,6 +94,7 @@ grokkit/
- **`internal/errors/`**: Domain-specific error types - **`internal/errors/`**: Domain-specific error types
- **`internal/git/`**: Git command abstraction - **`internal/git/`**: Git command abstraction
- **`internal/grok/`**: API client with streaming support - **`internal/grok/`**: API client with streaming support
- **`internal/linter/`**: Language detection and linter execution
- **`internal/logger/`**: Structured logging facade - **`internal/logger/`**: Structured logging facade
## Core Components ## Core Components
@ -204,7 +208,51 @@ func Run(args []string) (string, error) {
- Testability via interface - Testability via interface
- Future: git library instead of exec - 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 **Responsibility:** Structured logging with context
@ -321,6 +369,41 @@ exec.Command("git", "commit") # Execute commit
├─→ logger.Info() # Log result ├─→ 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 ## Testing Strategy
### Unit Tests ### Unit Tests

View File

@ -510,6 +510,126 @@ mv ~/.config/grokkit/chat_history.new.json \
~/.config/grokkit/chat_history.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 ## Getting Help
If none of these solutions work: If none of these solutions work:

310
internal/linter/linter.go Normal file
View 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
}

View 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
}