Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
40 changes: 34 additions & 6 deletions pkg/codingcontext/selectors/selectors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions pkg/codingcontext/selectors/selectors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
Loading