Skip to content

Commit 73e2851

Browse files
authored
fix: handle array frontmatter values in selector matching (#213)
* test: add failing tests for array frontmatter selector matching * fix: handle array frontmatter values in selector matching * test: use YAML array format as well in integration test * fix: remove shadow
1 parent 17ef0c7 commit 73e2851

3 files changed

Lines changed: 137 additions & 6 deletions

File tree

integration_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,83 @@ Go specific guidelines.
413413
}
414414
}
415415

416+
// TestSelectorFiltering_LanguagesField verifies that the typed `languages` field
417+
// on RuleFrontMatter participates in selector matching via `-s languages=X`.
418+
func TestSelectorFiltering_LanguagesField(t *testing.T) {
419+
t.Parallel()
420+
dirs := setupTestDirs(t)
421+
422+
// Rule with inline array languages (YAML flow sequence)
423+
nodeRule := filepath.Join(dirs.rulesDir, "nodejs.md")
424+
425+
nodeContent := "---\nlanguages: [nodejs]\n---\n# Node.js Guidelines\n\nNode.js specific guidelines.\n"
426+
if err := os.WriteFile(nodeRule, []byte(nodeContent), 0o600); err != nil {
427+
t.Fatalf("failed to write nodejs rule file: %v", err)
428+
}
429+
430+
// Rule with multi-element array languages
431+
multiRule := filepath.Join(dirs.rulesDir, "multi-lang.md")
432+
433+
multiContent := `---
434+
languages:
435+
- go
436+
- python
437+
---
438+
# Multi-Language Guidelines
439+
440+
Go and Python guidelines.
441+
`
442+
if err := os.WriteFile(multiRule, []byte(multiContent), 0o600); err != nil {
443+
t.Fatalf("failed to write multi-lang rule file: %v", err)
444+
}
445+
446+
// Rule with scalar languages value
447+
rustRule := filepath.Join(dirs.rulesDir, "rust.md")
448+
449+
rustContent := `---
450+
languages:
451+
- rust
452+
---
453+
# Rust Guidelines
454+
455+
Rust specific guidelines.
456+
`
457+
if err := os.WriteFile(rustRule, []byte(rustContent), 0o600); err != nil {
458+
t.Fatalf("failed to write rust rule file: %v", err)
459+
}
460+
461+
createStandardTask(t, dirs.tasksDir)
462+
463+
// Run with selector filtering for nodejs
464+
output := runTool(t, "-C", dirs.tmpDir, "-s", "languages=nodejs", "test-task")
465+
466+
// The nodejs rule (languages: [nodejs]) should be included
467+
if !strings.Contains(output, "# Node.js Guidelines") {
468+
t.Errorf("Node.js guidelines not found in output when filtering for languages=nodejs")
469+
}
470+
471+
// The multi-lang rule should NOT be included (it has [go, python], not nodejs)
472+
if strings.Contains(output, "# Multi-Language Guidelines") {
473+
t.Errorf("Multi-language guidelines should not be in output when filtering for nodejs")
474+
}
475+
476+
// The rust rule should NOT be included
477+
if strings.Contains(output, "# Rust Guidelines") {
478+
t.Errorf("Rust guidelines should not be in output when filtering for nodejs")
479+
}
480+
481+
// Run with selector filtering for python — should match multi-lang rule
482+
output2 := runTool(t, "-C", dirs.tmpDir, "-s", "languages=python", "test-task")
483+
484+
if !strings.Contains(output2, "# Multi-Language Guidelines") {
485+
t.Errorf("Multi-language guidelines not found in output when filtering for languages=python")
486+
}
487+
488+
if strings.Contains(output2, "# Node.js Guidelines") {
489+
t.Errorf("Node.js guidelines should not be in output when filtering for python")
490+
}
491+
}
492+
416493
func TestTemplateExpansionWithOsExpand(t *testing.T) {
417494
t.Parallel()
418495
tmpDir := t.TempDir()

pkg/codingcontext/selectors/selectors.go

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,23 @@ func (s *Selectors) GetValue(key, value string) bool {
101101
return innerMap[value]
102102
}
103103

104+
// toStringSlice converts a frontmatter value to a slice of strings.
105+
// Slices are expanded element-wise; scalars become a single-element slice.
106+
func toStringSlice(v any) []string {
107+
switch val := v.(type) {
108+
case []any:
109+
out := make([]string, 0, len(val))
110+
for _, elem := range val {
111+
out = append(out, fmt.Sprint(elem))
112+
}
113+
return out
114+
case []string:
115+
return val
116+
default:
117+
return []string{fmt.Sprint(v)}
118+
}
119+
}
120+
104121
// MatchesIncludes returns whether the frontmatter matches all include selectors,
105122
// along with a human-readable reason explaining the result.
106123
// 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
129146
continue
130147
}
131148

132-
fmStr := fmt.Sprint(fmValue)
133-
if values[fmStr] {
134-
// This selector matched
135-
matchedSelectors = append(matchedSelectors, fmt.Sprintf("%s=%s", key, fmStr))
136-
} else {
137-
// This selector didn't match
149+
// Flatten the frontmatter value into a list of strings so that
150+
// YAML arrays (e.g. languages: [go, python]) are compared
151+
// element-by-element instead of being stringified as "[go python]".
152+
fmStrings := toStringSlice(fmValue)
153+
154+
matched := false
155+
for _, fmStr := range fmStrings {
156+
if values[fmStr] {
157+
matchedSelectors = append(matchedSelectors, fmt.Sprintf("%s=%s", key, fmStr))
158+
matched = true
159+
160+
break
161+
}
162+
}
163+
164+
if !matched {
138165
var expectedValues []string
139166
for val := range values {
140167
expectedValues = append(expectedValues, val)
141168
}
142169

170+
fmStr := strings.Join(fmStrings, ", ")
143171
if len(expectedValues) == 1 {
144172
noMatchReasons = append(noMatchReasons, fmt.Sprintf("%s=%s (expected %s=%s)", key, fmStr, key, expectedValues[0]))
145173
} else {

pkg/codingcontext/selectors/selectors_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,32 @@ func matchesIncludesCases() []matchesIncludesCase {
164164
frontmatter: fm(map[string]any{"env": "production"}), wantMatch: false},
165165
{name: "empty value selector - key missing in frontmatter (match)", selectors: []string{"env="},
166166
frontmatter: fm(map[string]any{"language": "go"}), wantMatch: true},
167+
{name: "array frontmatter value - single element match", selectors: []string{},
168+
frontmatter: fm(map[string]any{"languages": []interface{}{"nodejs"}}), wantMatch: true,
169+
setupSelectors: func(s Selectors) {
170+
s.SetValue("languages", "nodejs")
171+
s.SetValue("languages", "python")
172+
}},
173+
{name: "array frontmatter value - single element no match", selectors: []string{},
174+
frontmatter: fm(map[string]any{"languages": []interface{}{"java"}}), wantMatch: false,
175+
setupSelectors: func(s Selectors) {
176+
s.SetValue("languages", "nodejs")
177+
s.SetValue("languages", "python")
178+
}},
179+
{name: "array frontmatter value - multi element match", selectors: []string{},
180+
frontmatter: fm(map[string]any{"languages": []interface{}{"go", "python"}}), wantMatch: true,
181+
setupSelectors: func(s Selectors) {
182+
s.SetValue("languages", "nodejs")
183+
s.SetValue("languages", "python")
184+
}},
185+
{name: "array frontmatter value - multi element no match", selectors: []string{},
186+
frontmatter: fm(map[string]any{"languages": []interface{}{"java", "rust"}}), wantMatch: false,
187+
setupSelectors: func(s Selectors) {
188+
s.SetValue("languages", "nodejs")
189+
s.SetValue("languages", "python")
190+
}},
191+
{name: "array frontmatter value - with string selector", selectors: []string{"languages=nodejs"},
192+
frontmatter: fm(map[string]any{"languages": []interface{}{"nodejs"}}), wantMatch: true},
167193
// excludeUnmatched=true cases
168194
{name: "exclude by default - key missing", selectors: []string{"env=production"},
169195
frontmatter: fm(map[string]any{"language": "go"}), excludeUnmatched: true, wantMatch: false},

0 commit comments

Comments
 (0)