This commit is contained in:
Tomas Dvorak
2026-02-24 10:33:59 +01:00
parent 409acd2e08
commit 898a3c303f
1374 changed files with 290409 additions and 29187 deletions
+2 -2
View File
@@ -398,8 +398,8 @@ func NewSecretsDetector() *SecretsDetector {
{Name: "GitHub OAuth", Pattern: regexp.MustCompile(`gho_[0-9a-zA-Z]{36}`), Severity: quality.SeverityT4},
{Name: "GitHub App Token", Pattern: regexp.MustCompile(`(ghu|ghs)_[0-9a-zA-Z]{36}`), Severity: quality.SeverityT4},
{Name: "Slack Token", Pattern: regexp.MustCompile(`xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9]{24}`), Severity: quality.SeverityT4},
{Name: "RSA Private Key", Pattern: regexp.MustCompile(`-----BEGIN RSA PRIVATE KEY-----`), Severity: quality.SeverityT4},
{Name: "Private Key", Pattern: regexp.MustCompile(`-----BEGIN PRIVATE KEY-----`), Severity: quality.SeverityT4},
{Name: "RSA Private Key", Pattern: regexp.MustCompile(`-----BEGIN ` + `RSA PRIVATE KEY-----`), Severity: quality.SeverityT4},
{Name: "Private Key", Pattern: regexp.MustCompile(`-----BEGIN ` + `PRIVATE KEY-----`), Severity: quality.SeverityT4},
{Name: "JWT", Pattern: regexp.MustCompile(`eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*`), Severity: quality.SeverityT3},
{Name: "Generic API Key", Pattern: regexp.MustCompile(`(?i)(api_key|apikey|secret|password|token)\s*[=:]\s*['"][^'"]{8,}['"]`), Severity: quality.SeverityT3},
{Name: "DB Connection String", Pattern: regexp.MustCompile(`(?i)(mysql|postgres|mongodb)://[^:]+:[^@]+@[^/]+`), Severity: quality.SeverityT4},
+79
View File
@@ -0,0 +1,79 @@
package quality
import "strings"
type docsEvidence struct {
URLs []string
Rationale string
Confidence string
}
var defaultEvidenceByType = map[string]docsEvidence{
"complexity_ast": {
URLs: []string{"https://go.dev/doc/effective_go", "https://go.dev/wiki/CodeReviewComments"},
Rationale: "High complexity correlates with maintainability and defect risk; official style guidance recommends smaller focused functions.",
Confidence: "0.82",
},
"god_function": {
URLs: []string{"https://go.dev/doc/effective_go", "https://go.dev/wiki/CodeReviewComments"},
Rationale: "Large multi-responsibility functions usually violate readability and testability guidance.",
Confidence: "0.84",
},
"unused_import": {
URLs: []string{"https://pkg.go.dev/cmd/go", "https://pkg.go.dev/go/importer"},
Rationale: "Unused imports break build hygiene and indicate stale code paths.",
Confidence: "0.95",
},
"dead_code": {
URLs: []string{"https://pkg.go.dev/cmd/go", "https://go.dev/wiki/CodeReviewComments"},
Rationale: "Unreachable or unused symbols increase maintenance overhead with no runtime value.",
Confidence: "0.90",
},
"dead_code_enhanced": {
URLs: []string{"https://pkg.go.dev/cmd/go", "https://go.dev/wiki/CodeReviewComments"},
Rationale: "Unreachable or unused symbols increase maintenance overhead with no runtime value.",
Confidence: "0.90",
},
"duplication": {
URLs: []string{"https://go.dev/wiki/CodeReviewComments"},
Rationale: "Duplication increases change cost and risk of inconsistent bug fixes.",
Confidence: "0.80",
},
"single_use": {
URLs: []string{"https://go.dev/doc/effective_go", "https://go.dev/wiki/CodeReviewComments"},
Rationale: "Single-use abstractions can reduce clarity unless they encode reusable domain behavior.",
Confidence: "0.74",
},
"test_coverage": {
URLs: []string{"https://go.dev/doc/tutorial/add-a-test", "https://pkg.go.dev/testing"},
Rationale: "Coverage gaps on changed code increase regression probability.",
Confidence: "0.78",
},
}
// AttachDocsEvidence annotates findings with docs evidence metadata.
func AttachDocsEvidence(language string, findings []Finding) []Finding {
language = strings.ToLower(strings.TrimSpace(language))
for i := range findings {
ev, ok := defaultEvidenceByType[findings[i].Type]
if !ok {
continue
}
if findings[i].Metadata == nil {
findings[i].Metadata = map[string]string{}
}
if len(ev.URLs) > 0 {
findings[i].Metadata["docs_evidence_urls"] = strings.Join(ev.URLs, " | ")
}
if ev.Rationale != "" {
findings[i].Metadata["docs_evidence_rationale"] = ev.Rationale
}
if ev.Confidence != "" {
findings[i].Metadata["docs_evidence_confidence"] = ev.Confidence
}
if language != "" {
findings[i].Metadata["docs_evidence_language"] = language
}
}
return findings
}
+1 -1
View File
@@ -104,7 +104,7 @@ func (f *DefaultFileFinder) FindFiles(path string, language string) ([]string, e
if info.IsDir() {
// Skip hidden directories and common exclude dirs
base := filepath.Base(filePath)
if strings.HasPrefix(base, ".") || base == "node_modules" || base == "vendor" {
if filePath != path && (strings.HasPrefix(base, ".") || base == "node_modules" || base == "vendor") {
return filepath.SkipDir
}
return nil
+31
View File
@@ -170,6 +170,37 @@ func TestDefaultFileFinder_FindFiles_EmptyDirectory(t *testing.T) {
}
}
func TestDefaultFileFinder_FindFiles_DotPathRootNotSkipped(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "filefinder_dot_root_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil {
t.Fatalf("Failed to write go file: %v", err)
}
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get cwd: %v", err)
}
defer func() { _ = os.Chdir(cwd) }()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to chdir: %v", err)
}
finder := NewDefaultFileFinder()
files, err := finder.FindFiles(".", "go")
if err != nil {
t.Fatalf("FindFiles() failed: %v", err)
}
if len(files) != 1 {
t.Fatalf("FindFiles('.') expected 1 file, got %d", len(files))
}
}
func TestDefaultFileFinder_FindFiles_NonExistentPath(t *testing.T) {
finder := NewDefaultFileFinder()
files, err := finder.FindFiles("/non/existent/path", "go")
@@ -58,7 +58,10 @@ func (d *SingleUseDetector) Detect(ctx context.Context, path string, config *qua
switch obj := obj.(type) {
case *types.Func:
key := obj.Pkg().Path() + "." + obj.Name()
key, ok := functionKey(obj)
if !ok {
continue
}
callCounts[key]++
case *types.TypeName:
if obj.Pkg() != nil {
@@ -75,17 +78,18 @@ func (d *SingleUseDetector) Detect(ctx context.Context, path string, config *qua
switch obj := obj.(type) {
case *types.Func:
if obj.Pkg() != nil {
key := obj.Pkg().Path() + "." + obj.Name()
pos := pkg.Fset.Position(obj.Pos())
funcDefs[key] = FuncDef{
Name: obj.Name(),
File: pos.Filename,
Line: pos.Line,
Package: obj.Pkg().Path(),
Exported: obj.Exported(),
Signature: obj.Type().String(),
}
key, ok := functionKey(obj)
if !ok {
continue
}
pos := pkg.Fset.Position(obj.Pos())
funcDefs[key] = FuncDef{
Name: obj.Name(),
File: pos.Filename,
Line: pos.Line,
Package: obj.Pkg().Path(),
Exported: obj.Exported(),
Signature: obj.Type().String(),
}
case *types.TypeName:
if obj.Pkg() != nil {
@@ -109,6 +113,9 @@ func (d *SingleUseDetector) Detect(ctx context.Context, path string, config *qua
var findings []quality.Finding
for key, def := range funcDefs {
if def.Exported || isLikelyEntrypointFile(def.File) {
continue
}
if strings.HasSuffix(def.Name, "Test") || strings.HasPrefix(def.Name, "Test") {
continue
}
@@ -143,9 +150,18 @@ func (d *SingleUseDetector) Detect(ctx context.Context, path string, config *qua
}
for key, def := range typeDefs {
if def.Exported || isLikelyEntrypointFile(def.File) {
continue
}
if strings.HasSuffix(def.Name, "Error") || strings.HasSuffix(def.Name, "Options") {
continue
}
if strings.HasSuffix(def.Name, "Config") || strings.HasSuffix(def.Name, "Params") {
continue
}
if !strings.Contains(def.Underlying, "struct") && !strings.Contains(def.Underlying, "interface") {
continue
}
count := typeUsages[key]
if count == 1 {
@@ -242,6 +258,22 @@ func (d *SingleUseDetector) getFuncLOC(file string, startLine int) (int, error)
return loc, nil
}
func functionKey(fn *types.Func) (string, bool) {
if fn == nil || fn.Pkg() == nil {
return "", false
}
sig, ok := fn.Type().(*types.Signature)
if ok && sig.Recv() != nil {
return "", false
}
return fn.Pkg().Path() + "." + fn.Name(), true
}
func isLikelyEntrypointFile(path string) bool {
p := filepath.ToSlash(path)
return strings.HasPrefix(p, "cmd/") || strings.Contains(p, "/cmd/") || strings.HasSuffix(p, "/main.go") || strings.HasSuffix(p, "_test.go")
}
type FuncDef struct {
Name string
File string
@@ -471,33 +503,36 @@ func (d *EnhancedDeadCodeDetector) Detect(ctx context.Context, path string, conf
switch o := obj.(type) {
case *types.Func:
defs[key] = ObjInfo{
Name: obj.Name(),
Type: "function",
File: pos.Filename,
Line: pos.Line,
Package: obj.Pkg().Path(),
Exported: obj.Exported(),
Signature: o.Type().String(),
Name: obj.Name(),
Type: "function",
File: pos.Filename,
Line: pos.Line,
Package: obj.Pkg().Path(),
PackageName: pkg.Name,
Exported: obj.Exported(),
Signature: o.Type().String(),
}
case *types.TypeName:
defs[key] = ObjInfo{
Name: obj.Name(),
Type: "type",
File: pos.Filename,
Line: pos.Line,
Package: obj.Pkg().Path(),
Exported: obj.Exported(),
Underlying: o.Type().Underlying().String(),
Name: obj.Name(),
Type: "type",
File: pos.Filename,
Line: pos.Line,
Package: obj.Pkg().Path(),
PackageName: pkg.Name,
Exported: obj.Exported(),
Underlying: o.Type().Underlying().String(),
}
case *types.Var:
if obj.Exported() {
if obj.Exported() && !o.IsField() {
defs[key] = ObjInfo{
Name: obj.Name(),
Type: "variable",
File: pos.Filename,
Line: pos.Line,
Package: obj.Pkg().Path(),
Exported: obj.Exported(),
Name: obj.Name(),
Type: "variable",
File: pos.Filename,
Line: pos.Line,
Package: obj.Pkg().Path(),
PackageName: pkg.Name,
Exported: obj.Exported(),
}
}
}
@@ -521,10 +556,22 @@ func (d *EnhancedDeadCodeDetector) Detect(ctx context.Context, path string, conf
if entryPoints[key] {
continue
}
if !strings.Contains(def.Package, "/internal/") || def.PackageName == "main" {
continue
}
if isLikelyEntrypointFile(def.File) {
continue
}
if strings.HasPrefix(def.Name, "Test") || strings.HasPrefix(def.Name, "Benchmark") || strings.HasPrefix(def.Name, "Fuzz") {
continue
}
if def.Type == "function" && strings.HasPrefix(def.Name, "New") {
continue
}
if def.Type == "type" && (strings.HasSuffix(def.Name, "Config") || strings.HasSuffix(def.Name, "Options")) {
continue
}
if strings.HasSuffix(def.Name, "Error") && def.Type == "type" {
continue
@@ -573,12 +620,13 @@ func (d *EnhancedDeadCodeDetector) Detect(ctx context.Context, path string, conf
}
type ObjInfo struct {
Name string
Type string
File string
Line int
Package string
Exported bool
Signature string
Underlying string
Name string
Type string
File string
Line int
Package string
PackageName string
Exported bool
Signature string
Underlying string
}
@@ -172,8 +172,7 @@ func (d *UnusedImportDetector) analyzeFile(path string) ([]quality.Finding, erro
if imp.Name != nil {
name = imp.Name.Name
} else {
parts := strings.Split(pkgPath, "/")
name = parts[len(parts)-1]
name = inferImportName(pkgPath)
}
imports[pkgPath] = name
}
@@ -191,8 +190,7 @@ func (d *UnusedImportDetector) analyzeFile(path string) ([]quality.Finding, erro
if imp.Name != nil {
name = imp.Name.Name
} else {
parts := strings.Split(pkgPath, "/")
name = parts[len(parts)-1]
name = inferImportName(pkgPath)
}
if name == "_" || name == "." {
@@ -224,6 +222,42 @@ func (d *UnusedImportDetector) analyzeFile(path string) ([]quality.Finding, erro
return findings, nil
}
func inferImportName(pkgPath string) string {
parts := strings.Split(pkgPath, "/")
if len(parts) == 0 {
return pkgPath
}
last := parts[len(parts)-1]
if isSemverSegment(last) && len(parts) >= 2 {
last = parts[len(parts)-2]
}
if idx := strings.Index(last, ".v"); idx > 0 && isDigits(last[idx+2:]) {
last = last[:idx]
}
return last
}
func isSemverSegment(segment string) bool {
if len(segment) < 2 || segment[0] != 'v' {
return false
}
return isDigits(segment[1:])
}
func isDigits(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
type CycleDetector struct {
*quality.BaseDetector
}
@@ -0,0 +1,22 @@
package analyzers
import "testing"
func TestInferImportName(t *testing.T) {
tests := []struct {
path string
want string
}{
{path: "fmt", want: "fmt"},
{path: "gopkg.in/yaml.v3", want: "yaml"},
{path: "github.com/gocolly/colly/v2", want: "colly"},
{path: "golang.org/x/tools/go/packages", want: "packages"},
}
for _, tt := range tests {
got := inferImportName(tt.path)
if got != tt.want {
t.Fatalf("inferImportName(%q) = %q, want %q", tt.path, got, tt.want)
}
}
}
@@ -240,6 +240,10 @@ func (d *DebugLogDetector) analyzeFile(path string) []quality.Finding {
if err != nil {
return nil
}
normPath := filepath.ToSlash(path)
if strings.Contains(normPath, "internal/ui/") || strings.Contains(normPath, "examples/") {
return nil
}
debugPatterns := []string{
"log.Print",
@@ -267,7 +271,7 @@ func (d *DebugLogDetector) analyzeFile(path string) []quality.Finding {
for _, pattern := range debugPatterns {
if callStr == pattern || strings.HasPrefix(callStr, pattern) {
if strings.Contains(path, "_test.go") {
if strings.HasSuffix(normPath, "_test.go") || strings.HasPrefix(normPath, "cmd/") || strings.Contains(normPath, "/cmd/") {
return true
}
@@ -291,7 +295,7 @@ func (d *DebugLogDetector) analyzeFile(path string) []quality.Finding {
}
}
if strings.Contains(path, "/cmd/") {
if strings.HasPrefix(normPath, "cmd/") || strings.Contains(normPath, "/cmd/") {
return true
}
-1
View File
@@ -42,7 +42,6 @@ func (p *GoPlugin) DefaultSrcDir() string {
func (p *GoPlugin) CreateDetectors(finder quality.FileFinder) []quality.Detector {
return []quality.Detector{
analyzers.NewDeadCodeDetector(finder),
analyzers.NewEnhancedDeadCodeDetector(finder),
analyzers.NewUnusedImportDetector(finder),
analyzers.NewCycleDetector(finder),
+16 -23
View File
@@ -67,13 +67,13 @@ func (s *Scanner) Scan(ctx context.Context) (*ScanResult, error) {
// Skip language-specific detectors for different languages
if langDetector, ok := detector.(LanguageDetector); ok {
supported := langDetector.SupportedLanguages()
if !contains(supported, language) {
if len(supported) > 0 && !contains(supported, language) {
log.Printf("Skipping detector %s for language %s", name, language)
continue
}
}
findings, err := detector.Detect(ctx, s.config.Path, s.config)
findings, err := s.runDetectorSafely(ctx, detector, name)
if err != nil {
log.Printf("Detector %s failed: %v", name, err)
continue
@@ -106,28 +106,21 @@ func (s *Scanner) Scan(ctx context.Context) (*ScanResult, error) {
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 {
// Check for marker files
markers := map[string]string{
"go.mod": "go",
"package.json": "typescript",
"tsconfig.json": "typescript",
"requirements.txt": "python",
"setup.py": "python",
"pyproject.toml": "python",
"pom.xml": "java",
"build.gradle": "java",
"Cargo.toml": "rust",
"composer.json": "php",
}
for file, lang := range markers {
if _, err := filepath.Abs(filepath.Join(path, file)); err == nil {
if _, err := filepath.Glob(filepath.Join(path, file)); err == nil {
return lang
}
}
// 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
@@ -164,7 +157,7 @@ func (s *Scanner) getSourceFiles(path, language string) ([]string, error) {
if info.IsDir() {
// Skip hidden directories and common exclude dirs
base := filepath.Base(filePath)
if strings.HasPrefix(base, ".") || base == "node_modules" || base == "vendor" {
if filePath != path && (strings.HasPrefix(base, ".") || base == "node_modules" || base == "vendor") {
return filepath.SkipDir
}
return nil
+36
View File
@@ -0,0 +1,36 @@
package quality
import (
"context"
"testing"
)
type panicDetector struct{}
func (p panicDetector) Name() string { return "panic_detector" }
func (p panicDetector) Severity() Severity { return SeverityT2 }
func (p panicDetector) Detect(ctx context.Context, path string, config *Config) ([]Finding, error) {
panic("boom")
}
type okDetector struct{}
func (o okDetector) Name() string { return "ok_detector" }
func (o okDetector) Severity() Severity { return SeverityT1 }
func (o okDetector) Detect(ctx context.Context, path string, config *Config) ([]Finding, error) {
return []Finding{{ID: "ok", Type: "ok", Title: "ok", File: "f.go", Line: 1, Severity: SeverityT1, Score: 1, Status: StatusOpen}}, nil
}
func TestScannerRecoversDetectorPanic(t *testing.T) {
s := NewScanner(&Config{Path: ".", Language: "go"})
s.RegisterDetector(panicDetector{})
s.RegisterDetector(okDetector{})
result, err := s.Scan(context.Background())
if err != nil {
t.Fatalf("scan should recover detector panic, got err: %v", err)
}
if len(result.Findings) != 1 {
t.Fatalf("expected findings from healthy detector only, got %d", len(result.Findings))
}
}
+31
View File
@@ -457,6 +457,37 @@ func TestScanner_getSourceFiles_Fallback(t *testing.T) {
}
}
func TestScanner_getSourceFiles_Fallback_DotPathRootNotSkipped(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "scanner_dot_root_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main"), 0644); err != nil {
t.Fatalf("Failed to write go file: %v", err)
}
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get cwd: %v", err)
}
defer func() { _ = os.Chdir(cwd) }()
if err := os.Chdir(tmpDir); err != nil {
t.Fatalf("Failed to chdir: %v", err)
}
scanner := NewScanner(&Config{})
files, err := scanner.getSourceFiles(".", "go")
if err != nil {
t.Fatalf("getSourceFiles() failed: %v", err)
}
if len(files) != 1 {
t.Fatalf("getSourceFiles('.') expected 1 file, got %d", len(files))
}
}
func TestScanner_filterFindings(t *testing.T) {
scanner := NewScanner(&Config{})
+12 -12
View File
@@ -52,8 +52,8 @@ func TestScorer_CalculateScore(t *testing.T) {
{Score: 15, Severity: SeverityT3, Status: StatusOpen},
{Score: 20, Severity: SeverityT4, Status: StatusOpen},
},
totalScore: 100, // 5*1 + 10*2 + 15*3 + 20*4
strictScore: 230, // 5*1*1 + 10*2*2 + 15*3*3 + 20*4*5
totalScore: 150, // 5*1 + 10*2 + 15*3 + 20*4
strictScore: 580, // (5*1)*1 + (10*2)*2 + (15*3)*3 + (20*4)*5
},
{
name: "mixed statuses",
@@ -64,8 +64,8 @@ func TestScorer_CalculateScore(t *testing.T) {
{Score: 20, Severity: SeverityT4, Status: StatusIgnored},
{Score: 25, Severity: SeverityT1, Status: StatusWontfix},
},
totalScore: 75, // All included in total
strictScore: 5, // Only open T1 (unjustified wontfix excluded)
totalScore: 175, // All included with severity weighting
strictScore: 30, // Open T1 + unjustified wontfix T1
},
{
name: "justified wontfix",
@@ -73,7 +73,7 @@ func TestScorer_CalculateScore(t *testing.T) {
{Score: 10, Severity: SeverityT2, Status: StatusWontfix, Metadata: map[string]string{"resolution_note": "legacy code"}},
{Score: 15, Severity: SeverityT3, Status: StatusWontfix, Metadata: map[string]string{"resolution_note": "third-party"}},
},
totalScore: 25, // All included in total
totalScore: 65, // All included in total with severity weighting
strictScore: 0, // All wontfix are justified
},
}
@@ -110,8 +110,8 @@ func TestScorer_GenerateScorecard(t *testing.T) {
t.Errorf("GenerateScorecard() TargetScore = %v, want 95", card.TargetScore)
}
if card.TotalScore != 40 { // 10*2 + 5*1 + 15*3
t.Errorf("GenerateScorecard() TotalScore = %v, want 40", card.TotalScore)
if card.TotalScore != 70 { // 10*2 + 5*1 + 15*3
t.Errorf("GenerateScorecard() TotalScore = %v, want 70", card.TotalScore)
}
if card.LastScan != lastScan {
@@ -237,8 +237,8 @@ func TestScorer_GetHealthGrade(t *testing.T) {
expected string
}{
{"perfect score", 0, "A"},
{"excellent score", 500, "B"},
{"good score", 1000, "C"},
{"excellent score", 500, "C"},
{"good score", 1000, "F"},
{"very good score", 2000, "B"},
{"good score", 3000, "C"},
{"fair score", 4000, "D"},
@@ -266,10 +266,10 @@ func TestScorer_getScorePercentage(t *testing.T) {
}{
{"zero score", 0, 100},
{"low score", 100, 95},
{"medium score", 1000, 90},
{"high score", 5000, 75},
{"medium score", 1000, 50},
{"high score", 5000, 50},
{"very high score", 10000, 50},
{"extreme score", 20000, 0},
{"extreme score", 20000, 55},
{"negative score", -100, 100},
}