diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go index 03717177..279a02c7 100644 --- a/pkg/validator/validator.go +++ b/pkg/validator/validator.go @@ -137,8 +137,9 @@ func (v *Validator) collectByPatterns(dir string, patterns []string) ([]string, func (v *Validator) matchPattern(dir, pattern string) ([]string, error) { var files []string - // Handle "**" for recursive matching - if pattern == "**" || strings.HasPrefix(pattern, "**/") { + // filepath.Glob does not understand "**", so route any pattern that + // contains it through the recursive walker. + if strings.Contains(pattern, "**") { return v.collectRecursive(dir, pattern) } @@ -177,17 +178,85 @@ func (v *Validator) matchPattern(dir, pattern string) ([]string, error) { return files, nil } -// collectRecursive collects all flow files recursively. +// collectRecursive collects flow files matching a pattern that contains "**". +// The pattern is split on the first "**" into an optional prefix (walked from) +// and an optional suffix (matched per-segment against each file's trailing +// path). E.g. "tests/**/*.yaml" walks from dir/tests and keeps files whose +// name matches "*.yaml". A prefix containing shell wildcards is expanded via +// filepath.Glob so patterns like "flows-*/**/*.yaml" walk each matching +// directory. A prefix that resolves to no existing directory is a silent +// no-match. func (v *Validator) collectRecursive(dir, pattern string) ([]string, error) { + idx := strings.Index(pattern, "**") + if idx < 0 { + return nil, fmt.Errorf("collectRecursive called without ** in pattern %q", pattern) + } + prefix := strings.TrimSuffix(pattern[:idx], "/") + suffix := strings.TrimPrefix(pattern[idx+2:], "/") + + roots, err := expandPrefixRoots(dir, prefix) + if err != nil { + return nil, err + } + var files []string + seen := make(map[string]bool) + for _, root := range roots { + matches, err := walkRecursive(root, suffix) + if err != nil { + return nil, err + } + for _, m := range matches { + if !seen[m] { + seen[m] = true + files = append(files, m) + } + } + } + return files, nil +} + +// expandPrefixRoots resolves the "**" prefix into concrete walk roots. If the +// prefix contains no wildcards, the single joined path is returned (or nothing +// if it doesn't exist). If the prefix contains "*"/"?"/"[", filepath.Glob is +// used to expand it and only the resulting directories are returned. +func expandPrefixRoots(dir, prefix string) ([]string, error) { + if prefix == "" { + return []string{dir}, nil + } - // Get the suffix after **/ if any - suffix := "" - if strings.HasPrefix(pattern, "**/") { - suffix = pattern[3:] + joined := filepath.Join(dir, prefix) + if !strings.ContainsAny(prefix, "*?[") { + if _, err := os.Stat(joined); err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + return []string{joined}, nil } - err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { + matches, err := filepath.Glob(joined) + if err != nil { + return nil, err + } + roots := make([]string, 0, len(matches)) + for _, m := range matches { + info, statErr := os.Stat(m) + if statErr != nil || !info.IsDir() { + continue + } + roots = append(roots, m) + } + return roots, nil +} + +// walkRecursive walks a single root and returns every flow file whose +// trailing path segments match the suffix (or every flow file if the suffix +// is empty). +func walkRecursive(root, suffix string) ([]string, error) { + var files []string + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return err } @@ -197,22 +266,39 @@ func (v *Validator) collectRecursive(dir, pattern string) ([]string, error) { if !isFlowFile(path) { return nil } - - // If there's a suffix pattern, check if filename matches if suffix != "" { - matched, _ := filepath.Match(suffix, filepath.Base(path)) - if !matched { + rel, relErr := filepath.Rel(root, path) + if relErr != nil { + return nil + } + if !matchTail(filepath.ToSlash(rel), suffix) { return nil } } - files = append(files, path) return nil }) - return files, err } +// matchTail reports whether the trailing "/"-separated segments of rel match +// the per-segment glob pattern suffix. +func matchTail(rel, suffix string) bool { + suffixParts := strings.Split(suffix, "/") + relParts := strings.Split(rel, "/") + if len(relParts) < len(suffixParts) { + return false + } + tail := relParts[len(relParts)-len(suffixParts):] + for i, seg := range suffixParts { + matched, err := filepath.Match(seg, tail[i]) + if err != nil || !matched { + return false + } + } + return true +} + // getTopLevelFlows gets flow files directly in a directory (not recursive). func (v *Validator) getTopLevelFlows(dir string) ([]string, error) { var files []string diff --git a/pkg/validator/validator_test.go b/pkg/validator/validator_test.go index 052a1072..84a31150 100644 --- a/pkg/validator/validator_test.go +++ b/pkg/validator/validator_test.go @@ -1218,3 +1218,203 @@ func TestValidate_GetTopLevelFlowsWithMixedContent(t *testing.T) { t.Errorf("expected 3 test cases, got %d: %v", len(result.TestCases), result.TestCases) } } + +// writeFlow is a shared helper for the recursive-pattern tests below. +// Creates any missing parent directories and writes a minimal-but-valid flow. +func writeFlow(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(`- tapOn: "X"`), 0o644); err != nil { + t.Fatal(err) + } +} + +// TestValidate_RecursivePatternWithPrefix verifies that "**" is treated as +// recursive when it appears after a directory prefix, e.g. "tests/**/*.yaml". +// Previously only patterns starting with "**" or "**/" were expanded +// recursively; anything with a prefix fell through to filepath.Glob, which +// treats "**" like a single "*" and therefore matched only one directory +// level, silently skipping deeper flows. +func TestValidate_RecursivePatternWithPrefix(t *testing.T) { + dir := t.TempDir() + + files := []string{ + "tests/a.yaml", + "tests/sub/b.yaml", + "tests/sub/deep/c.yaml", + "tests/sub/deep/more/d.yaml", + } + for _, p := range files { + writeFlow(t, filepath.Join(dir, p)) + } + // Sibling directory that must NOT be picked up. + writeFlow(t, filepath.Join(dir, "other", "skip.yaml")) + + config := `flows: + - "tests/**/*.yaml" +` + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(config), 0o644); err != nil { + t.Fatal(err) + } + + result := New(nil, nil).Validate(dir) + + if !result.IsValid() { + t.Fatalf("expected valid result, got errors: %v", result.Errors) + } + if len(result.TestCases) != len(files) { + t.Errorf("expected %d test cases from tests/**/*.yaml, got %d: %v", + len(files), len(result.TestCases), result.TestCases) + } + for _, tc := range result.TestCases { + if strings.Contains(filepath.ToSlash(tc), "/other/") { + t.Errorf("test case leaked from sibling dir: %s", tc) + } + } +} + +// TestValidate_RecursivePatternPrefixOnly verifies that "prefix/**" matches +// every flow file underneath the prefix at any depth, without requiring a +// filename suffix in the pattern. +func TestValidate_RecursivePatternPrefixOnly(t *testing.T) { + dir := t.TempDir() + + files := []string{ + "flows/a.yaml", + "flows/sub/b.yaml", + "flows/sub/deep/c.yaml", + } + for _, p := range files { + writeFlow(t, filepath.Join(dir, p)) + } + + config := `flows: + - "flows/**" +` + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(config), 0o644); err != nil { + t.Fatal(err) + } + + result := New(nil, nil).Validate(dir) + + if !result.IsValid() { + t.Fatalf("expected valid result, got errors: %v", result.Errors) + } + if len(result.TestCases) != len(files) { + t.Errorf("expected %d test cases from flows/**, got %d: %v", + len(files), len(result.TestCases), result.TestCases) + } +} + +// TestValidate_RecursivePatternMultiSegmentSuffix verifies that a pattern +// like "a/**/c/*.yaml" walks recursively from dir/a and then matches only +// files whose trailing path is "c/.yaml". +func TestValidate_RecursivePatternMultiSegmentSuffix(t *testing.T) { + dir := t.TempDir() + + included := []string{ + "a/c/one.yaml", + "a/x/c/two.yaml", + "a/x/y/c/three.yaml", + } + excluded := []string{ + "a/d/one.yaml", + "a/x/y/four.yaml", + } + for _, p := range append(included, excluded...) { + writeFlow(t, filepath.Join(dir, p)) + } + + config := `flows: + - "a/**/c/*.yaml" +` + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(config), 0o644); err != nil { + t.Fatal(err) + } + + result := New(nil, nil).Validate(dir) + + if !result.IsValid() { + t.Fatalf("expected valid result, got errors: %v", result.Errors) + } + if len(result.TestCases) != len(included) { + t.Errorf("expected %d test cases matching a/**/c/*.yaml, got %d: %v", + len(included), len(result.TestCases), result.TestCases) + } + for _, tc := range result.TestCases { + rel := filepath.ToSlash(tc) + if !strings.Contains(rel, "/c/") { + t.Errorf("unexpected match outside c/ suffix: %s", tc) + } + } +} + +// TestValidate_RecursivePatternMissingPrefixDir verifies that a pattern whose +// prefix directory does not exist (e.g. "subflows/**/*.yaml" with no +// subflows/) is a silent no-match rather than a walk error. +func TestValidate_RecursivePatternMissingPrefixDir(t *testing.T) { + dir := t.TempDir() + + // Only a top-level flow that should NOT match the prefixed pattern. + writeFlow(t, filepath.Join(dir, "top.yaml")) + + config := `flows: + - "subflows/**/*.yaml" +` + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(config), 0o644); err != nil { + t.Fatal(err) + } + + result := New(nil, nil).Validate(dir) + + if !result.IsValid() { + t.Fatalf("expected valid result for missing prefix dir, got errors: %v", result.Errors) + } + if len(result.TestCases) != 0 { + t.Errorf("expected 0 test cases, got %d: %v", len(result.TestCases), result.TestCases) + } +} + +// TestValidate_RecursivePatternWildcardPrefix verifies that a shell-wildcard +// in the prefix (before "**") is expanded via filepath.Glob, so +// "flows-*/**/*.yaml" walks each matching directory. +func TestValidate_RecursivePatternWildcardPrefix(t *testing.T) { + dir := t.TempDir() + + included := []string{ + "flows-alpha/a.yaml", + "flows-alpha/sub/b.yaml", + "flows-beta/c.yaml", + } + // Must NOT match — prefix wildcard "flows-*" doesn't include "other-dir". + excluded := []string{ + "other-dir/skip.yaml", + } + for _, p := range append(included, excluded...) { + writeFlow(t, filepath.Join(dir, p)) + } + + config := `flows: + - "flows-*/**/*.yaml" +` + if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte(config), 0o644); err != nil { + t.Fatal(err) + } + + result := New(nil, nil).Validate(dir) + + if !result.IsValid() { + t.Fatalf("expected valid result, got errors: %v", result.Errors) + } + if len(result.TestCases) != len(included) { + t.Errorf("expected %d test cases matching flows-*/**/*.yaml, got %d: %v", + len(included), len(result.TestCases), result.TestCases) + } + for _, tc := range result.TestCases { + if strings.Contains(filepath.ToSlash(tc), "/other-dir/") { + t.Errorf("test case leaked from sibling dir: %s", tc) + } + } +}