From 7d93fcb85b7ddcd268598523e77ec5b1fc10badf Mon Sep 17 00:00:00 2001 From: Conner Dunn Date: Fri, 3 Apr 2026 18:26:58 -0700 Subject: [PATCH 1/4] test: add failing tests for array frontmatter selector matching --- integration_test.go | 84 +++++++++++++++++++ pkg/codingcontext/selectors/selectors_test.go | 27 ++++++ 2 files changed, 111 insertions(+) diff --git a/integration_test.go b/integration_test.go index ee6e4b19..ab3d2c83 100644 --- a/integration_test.go +++ b/integration_test.go @@ -413,6 +413,90 @@ 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 array languages (the real-world pattern from bot-code-prompts) + nodeRule := filepath.Join(dirs.rulesDir, "nodejs.md") + + nodeContent := `--- +languages: + - nodejs +--- +# Node.js Guidelines + +Node.js specific guidelines. +` + 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_test.go b/pkg/codingcontext/selectors/selectors_test.go index e06f9f7f..48f4ff53 100644 --- a/pkg/codingcontext/selectors/selectors_test.go +++ b/pkg/codingcontext/selectors/selectors_test.go @@ -164,6 +164,33 @@ 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}, + // Array frontmatter values (YAML arrays like `languages: [nodejs]` are parsed as []interface{}) + {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}, From e9844562550ed91974d16f1559fede0284086cd4 Mon Sep 17 00:00:00 2001 From: Conner Dunn Date: Fri, 3 Apr 2026 18:32:53 -0700 Subject: [PATCH 2/4] fix: handle array frontmatter values in selector matching --- integration_test.go | 2 +- pkg/codingcontext/selectors/selectors.go | 40 ++++++++++++++++--- pkg/codingcontext/selectors/selectors_test.go | 1 - 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/integration_test.go b/integration_test.go index ab3d2c83..e06c6997 100644 --- a/integration_test.go +++ b/integration_test.go @@ -419,7 +419,7 @@ func TestSelectorFiltering_LanguagesField(t *testing.T) { t.Parallel() dirs := setupTestDirs(t) - // Rule with array languages (the real-world pattern from bot-code-prompts) + // Rule with array languages nodeRule := filepath.Join(dirs.rulesDir, "nodejs.md") nodeContent := `--- diff --git a/pkg/codingcontext/selectors/selectors.go b/pkg/codingcontext/selectors/selectors.go index 95ff2fd2..597a4fc0 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 _, s := range fmStrings { + if values[s] { + matchedSelectors = append(matchedSelectors, fmt.Sprintf("%s=%s", key, s)) + 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 48f4ff53..018d737d 100644 --- a/pkg/codingcontext/selectors/selectors_test.go +++ b/pkg/codingcontext/selectors/selectors_test.go @@ -164,7 +164,6 @@ 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}, - // Array frontmatter values (YAML arrays like `languages: [nodejs]` are parsed as []interface{}) {name: "array frontmatter value - single element match", selectors: []string{}, frontmatter: fm(map[string]any{"languages": []interface{}{"nodejs"}}), wantMatch: true, setupSelectors: func(s Selectors) { From 83bd14f39a57ec27528c08ac3a837cfff910e4ec Mon Sep 17 00:00:00 2001 From: Conner Dunn Date: Fri, 3 Apr 2026 18:59:46 -0700 Subject: [PATCH 3/4] test: use YAML array format as well in integration test --- integration_test.go | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/integration_test.go b/integration_test.go index e06c6997..5671aafb 100644 --- a/integration_test.go +++ b/integration_test.go @@ -419,17 +419,10 @@ func TestSelectorFiltering_LanguagesField(t *testing.T) { t.Parallel() dirs := setupTestDirs(t) - // Rule with array languages + // Rule with inline array languages (YAML flow sequence) nodeRule := filepath.Join(dirs.rulesDir, "nodejs.md") - nodeContent := `--- -languages: - - nodejs ---- -# Node.js Guidelines - -Node.js specific guidelines. -` + 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) } From af192c254c83007f0d340cb82f8ee9ae05f875b8 Mon Sep 17 00:00:00 2001 From: Conner Dunn Date: Fri, 3 Apr 2026 19:18:53 -0700 Subject: [PATCH 4/4] fix: remove shadow --- pkg/codingcontext/selectors/selectors.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/codingcontext/selectors/selectors.go b/pkg/codingcontext/selectors/selectors.go index 597a4fc0..a255133f 100644 --- a/pkg/codingcontext/selectors/selectors.go +++ b/pkg/codingcontext/selectors/selectors.go @@ -152,9 +152,9 @@ func (s *Selectors) MatchesIncludes(frontmatter markdown.BaseFrontMatter, includ fmStrings := toStringSlice(fmValue) matched := false - for _, s := range fmStrings { - if values[s] { - matchedSelectors = append(matchedSelectors, fmt.Sprintf("%s=%s", key, s)) + for _, fmStr := range fmStrings { + if values[fmStr] { + matchedSelectors = append(matchedSelectors, fmt.Sprintf("%s=%s", key, fmStr)) matched = true break