diff --git a/integration_test.go b/integration_test.go index ee6e4b19..5671aafb 100644 --- a/integration_test.go +++ b/integration_test.go @@ -413,6 +413,83 @@ Go specific guidelines. } } +// TestSelectorFiltering_LanguagesField verifies that the typed `languages` field +// on RuleFrontMatter participates in selector matching via `-s languages=X`. +func TestSelectorFiltering_LanguagesField(t *testing.T) { + t.Parallel() + dirs := setupTestDirs(t) + + // Rule with inline array languages (YAML flow sequence) + nodeRule := filepath.Join(dirs.rulesDir, "nodejs.md") + + nodeContent := "---\nlanguages: [nodejs]\n---\n# Node.js Guidelines\n\nNode.js specific guidelines.\n" + if err := os.WriteFile(nodeRule, []byte(nodeContent), 0o600); err != nil { + t.Fatalf("failed to write nodejs rule file: %v", err) + } + + // Rule with multi-element array languages + multiRule := filepath.Join(dirs.rulesDir, "multi-lang.md") + + multiContent := `--- +languages: + - go + - python +--- +# Multi-Language Guidelines + +Go and Python guidelines. +` + if err := os.WriteFile(multiRule, []byte(multiContent), 0o600); err != nil { + t.Fatalf("failed to write multi-lang rule file: %v", err) + } + + // Rule with scalar languages value + rustRule := filepath.Join(dirs.rulesDir, "rust.md") + + rustContent := `--- +languages: + - rust +--- +# Rust Guidelines + +Rust specific guidelines. +` + if err := os.WriteFile(rustRule, []byte(rustContent), 0o600); err != nil { + t.Fatalf("failed to write rust rule file: %v", err) + } + + createStandardTask(t, dirs.tasksDir) + + // Run with selector filtering for nodejs + output := runTool(t, "-C", dirs.tmpDir, "-s", "languages=nodejs", "test-task") + + // The nodejs rule (languages: [nodejs]) should be included + if !strings.Contains(output, "# Node.js Guidelines") { + t.Errorf("Node.js guidelines not found in output when filtering for languages=nodejs") + } + + // The multi-lang rule should NOT be included (it has [go, python], not nodejs) + if strings.Contains(output, "# Multi-Language Guidelines") { + t.Errorf("Multi-language guidelines should not be in output when filtering for nodejs") + } + + // The rust rule should NOT be included + if strings.Contains(output, "# Rust Guidelines") { + t.Errorf("Rust guidelines should not be in output when filtering for nodejs") + } + + // Run with selector filtering for python — should match multi-lang rule + output2 := runTool(t, "-C", dirs.tmpDir, "-s", "languages=python", "test-task") + + if !strings.Contains(output2, "# Multi-Language Guidelines") { + t.Errorf("Multi-language guidelines not found in output when filtering for languages=python") + } + + if strings.Contains(output2, "# Node.js Guidelines") { + t.Errorf("Node.js guidelines should not be in output when filtering for python") + } +} + func TestTemplateExpansionWithOsExpand(t *testing.T) { t.Parallel() tmpDir := t.TempDir() diff --git a/pkg/codingcontext/selectors/selectors.go b/pkg/codingcontext/selectors/selectors.go index 95ff2fd2..a255133f 100644 --- a/pkg/codingcontext/selectors/selectors.go +++ b/pkg/codingcontext/selectors/selectors.go @@ -101,6 +101,23 @@ func (s *Selectors) GetValue(key, value string) bool { return innerMap[value] } +// toStringSlice converts a frontmatter value to a slice of strings. +// Slices are expanded element-wise; scalars become a single-element slice. +func toStringSlice(v any) []string { + switch val := v.(type) { + case []any: + out := make([]string, 0, len(val)) + for _, elem := range val { + out = append(out, fmt.Sprint(elem)) + } + return out + case []string: + return val + default: + return []string{fmt.Sprint(v)} + } +} + // MatchesIncludes returns whether the frontmatter matches all include selectors, // along with a human-readable reason explaining the result. // If a key doesn't exist in frontmatter, it's allowed when includeByDefault is true (the default). @@ -129,17 +146,28 @@ func (s *Selectors) MatchesIncludes(frontmatter markdown.BaseFrontMatter, includ continue } - fmStr := fmt.Sprint(fmValue) - if values[fmStr] { - // This selector matched - matchedSelectors = append(matchedSelectors, fmt.Sprintf("%s=%s", key, fmStr)) - } else { - // This selector didn't match + // Flatten the frontmatter value into a list of strings so that + // YAML arrays (e.g. languages: [go, python]) are compared + // element-by-element instead of being stringified as "[go python]". + fmStrings := toStringSlice(fmValue) + + matched := false + for _, fmStr := range fmStrings { + if values[fmStr] { + matchedSelectors = append(matchedSelectors, fmt.Sprintf("%s=%s", key, fmStr)) + matched = true + + break + } + } + + if !matched { var expectedValues []string for val := range values { expectedValues = append(expectedValues, val) } + fmStr := strings.Join(fmStrings, ", ") if len(expectedValues) == 1 { noMatchReasons = append(noMatchReasons, fmt.Sprintf("%s=%s (expected %s=%s)", key, fmStr, key, expectedValues[0])) } else { diff --git a/pkg/codingcontext/selectors/selectors_test.go b/pkg/codingcontext/selectors/selectors_test.go index e06f9f7f..018d737d 100644 --- a/pkg/codingcontext/selectors/selectors_test.go +++ b/pkg/codingcontext/selectors/selectors_test.go @@ -164,6 +164,32 @@ func matchesIncludesCases() []matchesIncludesCase { frontmatter: fm(map[string]any{"env": "production"}), wantMatch: false}, {name: "empty value selector - key missing in frontmatter (match)", selectors: []string{"env="}, frontmatter: fm(map[string]any{"language": "go"}), wantMatch: true}, + {name: "array frontmatter value - single element match", selectors: []string{}, + frontmatter: fm(map[string]any{"languages": []interface{}{"nodejs"}}), wantMatch: true, + setupSelectors: func(s Selectors) { + s.SetValue("languages", "nodejs") + s.SetValue("languages", "python") + }}, + {name: "array frontmatter value - single element no match", selectors: []string{}, + frontmatter: fm(map[string]any{"languages": []interface{}{"java"}}), wantMatch: false, + setupSelectors: func(s Selectors) { + s.SetValue("languages", "nodejs") + s.SetValue("languages", "python") + }}, + {name: "array frontmatter value - multi element match", selectors: []string{}, + frontmatter: fm(map[string]any{"languages": []interface{}{"go", "python"}}), wantMatch: true, + setupSelectors: func(s Selectors) { + s.SetValue("languages", "nodejs") + s.SetValue("languages", "python") + }}, + {name: "array frontmatter value - multi element no match", selectors: []string{}, + frontmatter: fm(map[string]any{"languages": []interface{}{"java", "rust"}}), wantMatch: false, + setupSelectors: func(s Selectors) { + s.SetValue("languages", "nodejs") + s.SetValue("languages", "python") + }}, + {name: "array frontmatter value - with string selector", selectors: []string{"languages=nodejs"}, + frontmatter: fm(map[string]any{"languages": []interface{}{"nodejs"}}), wantMatch: true}, // excludeUnmatched=true cases {name: "exclude by default - key missing", selectors: []string{"env=production"}, frontmatter: fm(map[string]any{"language": "go"}), excludeUnmatched: true, wantMatch: false},