Files
2026-02-24 12:10:13 +01:00

244 lines
6.0 KiB
Go

package quality
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
)
// Scanner orchestrates the code quality scanning process
type Scanner struct {
detectors map[string]Detector
finder FileFinder
config *Config
}
// NewScanner creates a new quality scanner
func NewScanner(config *Config) *Scanner {
return &Scanner{
detectors: make(map[string]Detector),
config: config,
}
}
// RegisterDetector registers a detector with the scanner
func (s *Scanner) RegisterDetector(detector Detector) {
s.detectors[detector.Name()] = detector
}
// SetFileFinder sets the file finder for the scanner
func (s *Scanner) SetFileFinder(finder FileFinder) {
s.finder = finder
}
// Scan performs a comprehensive quality scan
func (s *Scanner) Scan(ctx context.Context) (*ScanResult, error) {
start := time.Now()
log.Printf("Starting quality scan for path: %s", s.config.Path)
allFindings := make([]Finding, 0)
filesChecked := 0
// Determine language if not specified
language := s.config.Language
if language == "" {
language = s.detectLanguage(s.config.Path)
log.Printf("Auto-detected language: %s", language)
}
// Get source files
files, err := s.getSourceFiles(s.config.Path, language)
if err != nil {
return nil, fmt.Errorf("failed to get source files: %w", err)
}
filesChecked = len(files)
log.Printf("Found %d source files to analyze", filesChecked)
// Run all detectors
for name, detector := range s.detectors {
log.Printf("Running detector: %s", name)
// Skip language-specific detectors for different languages
if langDetector, ok := detector.(LanguageDetector); ok {
supported := langDetector.SupportedLanguages()
if len(supported) > 0 && !contains(supported, language) {
log.Printf("Skipping detector %s for language %s", name, language)
continue
}
}
findings, err := s.runDetectorSafely(ctx, detector, name)
if err != nil {
log.Printf("Detector %s failed: %v", name, err)
allFindings = append(allFindings, Finding{
ID: fmt.Sprintf("detector_error::%s", name),
Type: "detector_error",
Title: fmt.Sprintf("Detector failed: %s", name),
Description: fmt.Sprintf("Detector %s failed during scan: %v", name, err),
File: s.config.Path,
Line: 1,
Severity: SeverityT2,
Score: 0,
Status: StatusOpen,
Metadata: map[string]string{
"detector": name,
"error": err.Error(),
},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
continue
}
// Filter findings based on exclude patterns
filtered := s.filterFindings(findings)
allFindings = append(allFindings, filtered...)
log.Printf("Detector %s found %d issues", name, len(filtered))
}
// Calculate scores
score, strictScore := s.calculateScores(allFindings)
duration := time.Since(start)
result := &ScanResult{
Findings: allFindings,
Score: score,
StrictScore: strictScore,
FilesChecked: filesChecked,
Duration: duration.String(),
Timestamp: time.Now(),
}
log.Printf("Scan completed in %s: %d findings, score: %d (strict: %d)",
duration, len(allFindings), score, strictScore)
return result, nil
}
func (s *Scanner) runDetectorSafely(ctx context.Context, detector Detector, name string) (_ []Finding, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("detector panic in %s: %v", name, r)
}
}()
return detector.Detect(ctx, s.config.Path, s.config)
}
// detectLanguage attempts to auto-detect the project language
func (s *Scanner) detectLanguage(path string) string {
// Keep auto-detection intentionally conservative until full multi-language
// scanner behavior is validated in tests.
if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil {
return "go"
}
// Default to Go if no markers found
return "go"
}
// getSourceFiles gets all source files for the given language and path
func (s *Scanner) getSourceFiles(path, language string) ([]string, error) {
if s.finder != nil {
return s.finder.FindFiles(path, language)
}
// Fallback to basic file extension matching
extensions := map[string][]string{
"go": {".go"},
"typescript": {".ts", ".tsx"},
"python": {".py"},
"java": {".java"},
"rust": {".rs"},
"javascript": {".js", ".jsx"},
}
langExts, ok := extensions[language]
if !ok {
langExts = []string{".go"} // default to Go
}
var files []string
err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
// Skip hidden directories and common exclude dirs
base := filepath.Base(filePath)
if filePath != path && (strings.HasPrefix(base, ".") || base == "node_modules" || base == "vendor") {
return filepath.SkipDir
}
return nil
}
// Check file extension
ext := filepath.Ext(filePath)
for _, langExt := range langExts {
if ext == langExt {
if !ShouldExclude(filePath, s.config.Exclude) {
files = append(files, filePath)
}
break
}
}
return nil
})
return files, err
}
// filterFindings filters findings based on exclude patterns
func (s *Scanner) filterFindings(findings []Finding) []Finding {
if len(s.config.Exclude) == 0 {
return findings
}
var filtered []Finding
for _, finding := range findings {
if !ShouldExclude(finding.File, s.config.Exclude) {
filtered = append(filtered, finding)
}
}
return filtered
}
// calculateScores calculates quality scores based on findings
func (s *Scanner) calculateScores(findings []Finding) (int, int) {
totalScore := 0
strictScore := 0
for _, finding := range findings {
weight := int(finding.Severity)
score := finding.Score * weight
totalScore += score
// Strict score includes open and wontfix findings
if finding.Status == StatusOpen || finding.Status == StatusWontfix {
strictScore += score
}
}
return totalScore, strictScore
}
// contains checks if a slice contains a string
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}