fix(lint): preserve explicit empty file scopes

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-01 11:43:25 +00:00
parent 71529076b3
commit 5da4a1dbd1
6 changed files with 116 additions and 10 deletions

View file

@ -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

View file

@ -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))
}

View file

@ -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

View file

@ -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{
{

View file

@ -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)

View file

@ -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))