diff --git a/pkg/lint/detect_project.go b/pkg/lint/detect_project.go index 3de52b0..3f67295 100644 --- a/pkg/lint/detect_project.go +++ b/pkg/lint/detect_project.go @@ -47,6 +47,10 @@ func Detect(path string) []string { return sortedDetectedLanguages(seen) } + if shouldSkipTraversalRoot(path) { + return []string{} + } + _ = filepath.WalkDir(path, func(currentPath string, entry fs.DirEntry, walkErr error) error { if walkErr != nil { return nil diff --git a/pkg/lint/detect_project_test.go b/pkg/lint/detect_project_test.go index bfc028c..cbd3d54 100644 --- a/pkg/lint/detect_project_test.go +++ b/pkg/lint/detect_project_test.go @@ -48,3 +48,12 @@ func TestDetectFromFiles_Good(t *testing.T) { func TestDetect_MissingPathReturnsEmptySlice(t *testing.T) { assert.Equal(t, []string{}, Detect(filepath.Join(t.TempDir(), "missing"))) } + +func TestDetect_Good_SkipsHiddenRootDirectory(t *testing.T) { + dir := t.TempDir() + hiddenDir := filepath.Join(dir, ".core") + require.NoError(t, os.MkdirAll(hiddenDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(hiddenDir, "main.go"), []byte("package main\n"), 0o644)) + + assert.Equal(t, []string{}, Detect(hiddenDir)) +} diff --git a/pkg/lint/scanner.go b/pkg/lint/scanner.go index b060767..ba4d659 100644 --- a/pkg/lint/scanner.go +++ b/pkg/lint/scanner.go @@ -57,6 +57,20 @@ func DetectLanguage(filename string) string { return "" } +func shouldSkipTraversalRoot(path string) bool { + cleanedPath := filepath.Clean(path) + if cleanedPath == "." { + return false + } + + base := filepath.Base(cleanedPath) + if base == "." || base == string(filepath.Separator) { + return false + } + + return IsExcludedDir(base) +} + // Scanner walks directory trees and matches files against lint rules. type Scanner struct { matcher *Matcher @@ -82,6 +96,10 @@ func NewScanner(rules []Rule) (*Scanner, error) { func (s *Scanner) ScanDir(root string) ([]Finding, error) { var findings []Finding + if shouldSkipTraversalRoot(root) { + return findings, nil + } + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { return err diff --git a/pkg/lint/scanner_test.go b/pkg/lint/scanner_test.go index 0fa58cd..cc6988d 100644 --- a/pkg/lint/scanner_test.go +++ b/pkg/lint/scanner_test.go @@ -238,6 +238,32 @@ func TestScanDir_Good_Subdirectories(t *testing.T) { require.Len(t, findings, 1) } +func TestScanDir_Good_SkipsHiddenRootDirectory(t *testing.T) { + dir := t.TempDir() + hiddenDir := filepath.Join(dir, ".git") + require.NoError(t, os.MkdirAll(hiddenDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(hiddenDir, "main.go"), []byte("// TODO: hidden\n"), 0o644)) + + rules := []Rule{ + { + ID: "test-001", + Title: "Found a TODO", + Severity: "low", + Languages: []string{"go"}, + Pattern: `TODO`, + Fix: "Remove TODO", + Detection: "regex", + }, + } + + s, err := NewScanner(rules) + require.NoError(t, err) + + findings, err := s.ScanDir(hiddenDir) + require.NoError(t, err) + assert.Empty(t, findings) +} + func TestScanDir_Bad_NonexistentDir(t *testing.T) { rules := []Rule{ { diff --git a/pkg/lint/service.go b/pkg/lint/service.go index 1b98b92..9685ec1 100644 --- a/pkg/lint/service.go +++ b/pkg/lint/service.go @@ -102,7 +102,7 @@ func (s *Service) Run(ctx context.Context, input RunInput) (Report, error) { input.FailOn = config.FailOn } - files, err := s.scopeFiles(input.Path, config, input, schedule) + files, scoped, err := s.scopeFiles(input.Path, config, input, schedule) if err != nil { return Report{}, err } @@ -119,8 +119,21 @@ func (s *Service) Run(ctx context.Context, input RunInput) (Report, error) { report.Summary.Passed = passesThreshold(report.Summary, input.FailOn) return report, nil } + if scoped && len(files) == 0 { + report := Report{ + Project: projectName(input.Path), + Timestamp: startedAt, + Duration: time.Since(startedAt).Round(time.Millisecond).String(), + Languages: []string{}, + Tools: []ToolRun{}, + Findings: []Finding{}, + Summary: Summarise(nil), + } + report.Summary.Passed = passesThreshold(report.Summary, input.FailOn) + return report, nil + } - languages := s.languagesForInput(input, files) + languages := s.languagesForInput(input, files, scoped) selectedAdapters := s.selectAdapters(config, languages, input, schedule) var findings []Finding @@ -302,30 +315,33 @@ func (s *Service) RemoveHook(projectPath string) error { return nil } -func (s *Service) languagesForInput(input RunInput, files []string) []string { +func (s *Service) languagesForInput(input RunInput, files []string, scoped bool) []string { if input.Lang != "" { return []string{input.Lang} } - if len(files) > 0 { + if scoped { return detectFromFiles(files) } return Detect(input.Path) } -func (s *Service) scopeFiles(projectPath string, config LintConfig, input RunInput, schedule *Schedule) ([]string, error) { +func (s *Service) scopeFiles(projectPath string, config LintConfig, input RunInput, schedule *Schedule) ([]string, bool, error) { if len(input.Files) > 0 { - return slices.Clone(input.Files), nil + return slices.Clone(input.Files), true, nil } if input.Hook { - return s.stagedFiles(projectPath) + files, err := s.stagedFiles(projectPath) + return files, true, err } if schedule != nil && len(schedule.Paths) > 0 { - return collectConfiguredFiles(projectPath, schedule.Paths, config.Exclude) + files, err := collectConfiguredFiles(projectPath, schedule.Paths, config.Exclude) + return files, true, err } if !slices.Equal(config.Paths, DefaultConfig().Paths) || !slices.Equal(config.Exclude, DefaultConfig().Exclude) { - return collectConfiguredFiles(projectPath, config.Paths, config.Exclude) + files, err := collectConfiguredFiles(projectPath, config.Paths, config.Exclude) + return files, true, err } - return nil, nil + return nil, false, nil } func (s *Service) selectAdapters(config LintConfig, languages []string, input RunInput, schedule *Schedule) []Adapter { @@ -394,6 +410,9 @@ func collectConfiguredFiles(projectPath string, paths []string, excludes []strin if err != nil { return nil, coreerr.E("collectConfiguredFiles", "stat "+absolutePath, err) } + if info.IsDir() && shouldSkipTraversalRoot(absolutePath) { + continue + } addFile := func(candidate string) { relativePath := relativeConfiguredPath(projectPath, candidate) diff --git a/pkg/lint/service_test.go b/pkg/lint/service_test.go index 947ab3f..6b3ecd7 100644 --- a/pkg/lint/service_test.go +++ b/pkg/lint/service_test.go @@ -130,6 +130,36 @@ func Run() { assert.False(t, report.Summary.Passed) } +func TestServiceRun_Good_SkipsHiddenConfiguredRootDirectory(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/test\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".hidden"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".hidden", "scoped.go"), []byte(`package sample + +type service struct{} + +func (service) Process(string) error { return nil } + +func Run() { + svc := service{} + _ = svc.Process("scoped") +} +`), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, ".core"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, ".core", "lint.yaml"), []byte("paths:\n - .hidden\n"), 0o644)) + + svc := &Service{adapters: []Adapter{newCatalogAdapter()}} + report, err := svc.Run(context.Background(), RunInput{ + Path: dir, + FailOn: "warning", + }) + require.NoError(t, err) + + assert.Empty(t, report.Findings) + assert.Empty(t, report.Tools) + assert.True(t, report.Summary.Passed) +} + func TestServiceRun_Good_UsesNamedSchedule(t *testing.T) { dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/test\n"), 0o644))