Skip to content

Commit 417b717

Browse files
feat: add discover_cursor_rules
1 parent ea71e8c commit 417b717

6 files changed

Lines changed: 214 additions & 3 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ review:
114114
| `context.reserved_output_tokens` | Tokens reserved for the model's response (default: `4096`) |
115115
| `context.tokenizer_model` | Tiktoken encoding used for token counting (default: `o200k_base`) |
116116
| `context.model_limits` | Per-model context window and max output token settings |
117+
| `context.discover_cursor_rules` | When `true`, also read `AGENTS.md` and `.cursor/rules/*.mdc` (rules with `alwaysApply: true`, or no `globs`/`alwaysApply` frontmatter at all) from the reviewed repo as additional project context. Default: `false` |
117118

118119
### 4. Build
119120

@@ -164,7 +165,7 @@ go install ./cmd/codestrike
164165
ln -s "$(pwd)" ~/.cursor/plugins/local/codestrike
165166
```
166167

167-
Reload Cursor (`Developer: Reload Window`) and confirm `pr-review` appears under **Customize → Skills**. See [`docs/cursor-integration.md`](docs/cursor-integration.md) for details and planned follow-up work (an MCP server, richer `.cursor/rules`/`AGENTS.md` context discovery).
168+
Reload Cursor (`Developer: Reload Window`) and confirm `pr-review` appears under **Customize → Skills**. codestrike's own review pipeline can also read Cursor-native project instructions (`AGENTS.md`, `.cursor/rules/*.mdc`) from the reviewed repo — see `context.discover_cursor_rules` above. See [`docs/cursor-integration.md`](docs/cursor-integration.md) for details and planned follow-up work (an MCP server).
168169

169170
## Development
170171

internal/config/assets/default.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ review:
2121
max_input_ratio: 0.75
2222
reserved_output_tokens: 4096
2323
tokenizer_model: o200k_base
24+
# Set to true to also read AGENTS.md and .cursor/rules/*.mdc (rules with
25+
# alwaysApply: true, or no globs/alwaysApply frontmatter at all) from the
26+
# reviewed repo as additional project context. Off by default.
27+
# discover_cursor_rules: true
2428
model_limits:
2529
gpt-4o:
2630
context_window: 128000

internal/config/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type ContextConfig struct {
3232
ReservedOutputTokens int `yaml:"reserved_output_tokens"`
3333
TokenizerModel string `yaml:"tokenizer_model"`
3434
ModelLimits map[string]ModelLimit `yaml:"model_limits"`
35+
DiscoverCursorRules bool `yaml:"discover_cursor_rules"`
3536
}
3637

3738
type ModelLimit struct {

internal/context/cursorrules.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package context
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
9+
"gopkg.in/yaml.v3"
10+
)
11+
12+
// cursorRuleFrontmatter mirrors the subset of Cursor's .mdc frontmatter
13+
// fields relevant to deciding whether a rule is unconditionally applicable
14+
// in a whole-PR review, where there is no single-file diff-aware matching
15+
// and no interactive chat session for @-mentions to happen in.
16+
type cursorRuleFrontmatter struct {
17+
Description string `yaml:"description"`
18+
Globs string `yaml:"globs"`
19+
AlwaysApply *bool `yaml:"alwaysApply"`
20+
}
21+
22+
// parseMDCFrontmatter splits an .mdc file into its frontmatter and body. ok
23+
// is false when the file has no valid "---" delimited frontmatter block, in
24+
// which case the caller should skip it.
25+
func parseMDCFrontmatter(data []byte) (fm cursorRuleFrontmatter, body string, ok bool) {
26+
s := string(data)
27+
if !strings.HasPrefix(s, "---\n") && !strings.HasPrefix(s, "---\r\n") {
28+
return cursorRuleFrontmatter{}, "", false
29+
}
30+
31+
rest := s[strings.IndexByte(s, '\n')+1:]
32+
end := strings.Index(rest, "\n---\n")
33+
sepLen := len("\n---\n")
34+
if end == -1 {
35+
end = strings.Index(rest, "\n---\r\n")
36+
sepLen = len("\n---\r\n")
37+
}
38+
if end == -1 {
39+
return cursorRuleFrontmatter{}, "", false
40+
}
41+
42+
raw := rest[:end]
43+
remainder := rest[end+sepLen:]
44+
45+
if err := yaml.Unmarshal([]byte(raw), &fm); err != nil {
46+
return cursorRuleFrontmatter{}, "", false
47+
}
48+
return fm, strings.TrimSpace(remainder), true
49+
}
50+
51+
// isUnconditionallyApplicable reports whether a rule should be folded into
52+
// a whole-PR review's project context: alwaysApply: true, or rules with no
53+
// file-scoping (globs) at all. Rules scoped to specific files via globs are
54+
// skipped, since a whole-PR review has no single file to match them against.
55+
func isUnconditionallyApplicable(fm cursorRuleFrontmatter) bool {
56+
if fm.AlwaysApply != nil {
57+
return *fm.AlwaysApply
58+
}
59+
return strings.TrimSpace(fm.Globs) == ""
60+
}
61+
62+
// DiscoverCursorContext reads AGENTS.md and applicable .cursor/rules/*.mdc
63+
// files from repoRoot (root level only, not recursive) and returns their
64+
// concatenated content formatted as "### <name>" sections, consistent with
65+
// how project context_files are rendered.
66+
func DiscoverCursorContext(repoRoot string) string {
67+
var sb strings.Builder
68+
69+
if data, err := os.ReadFile(filepath.Join(repoRoot, "AGENTS.md")); err == nil {
70+
if content := strings.TrimSpace(string(data)); content != "" {
71+
sb.WriteString("### AGENTS.md\n")
72+
sb.WriteString(content)
73+
sb.WriteString("\n\n")
74+
}
75+
}
76+
77+
rulesDir := filepath.Join(repoRoot, ".cursor", "rules")
78+
entries, err := os.ReadDir(rulesDir)
79+
if err != nil {
80+
return sb.String()
81+
}
82+
83+
for _, e := range entries {
84+
if e.IsDir() || filepath.Ext(e.Name()) != ".mdc" {
85+
continue
86+
}
87+
data, err := os.ReadFile(filepath.Join(rulesDir, e.Name()))
88+
if err != nil {
89+
continue
90+
}
91+
fm, body, ok := parseMDCFrontmatter(data)
92+
if !ok || body == "" || !isUnconditionallyApplicable(fm) {
93+
continue
94+
}
95+
fmt.Fprintf(&sb, "### .cursor/rules/%s\n%s\n\n", e.Name(), body)
96+
}
97+
98+
return sb.String()
99+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package context_test
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
9+
appcontext "github.com/CrowdStrike/codestrike/internal/context"
10+
)
11+
12+
func TestDiscoverCursorContext_ReadsAgentsMdAndAlwaysApplyRules(t *testing.T) {
13+
dir := t.TempDir()
14+
writeFile(t, dir, "AGENTS.md", "# Project Instructions\nUse snake_case.\n")
15+
16+
rulesDir := filepath.Join(dir, ".cursor", "rules")
17+
if err := os.MkdirAll(rulesDir, 0755); err != nil {
18+
t.Fatal(err)
19+
}
20+
writeFile(t, rulesDir, "always.mdc", "---\nalwaysApply: true\n---\n\nAlways follow this rule.\n")
21+
writeFile(t, rulesDir, "no-frontmatter-fields.mdc", "---\ndescription: agent decides\n---\n\nAgent-decided rule body.\n")
22+
23+
got := appcontext.DiscoverCursorContext(dir)
24+
25+
if !strings.Contains(got, "### AGENTS.md") || !strings.Contains(got, "Use snake_case.") {
26+
t.Errorf("expected AGENTS.md content in output, got: %q", got)
27+
}
28+
if !strings.Contains(got, "### .cursor/rules/always.mdc") || !strings.Contains(got, "Always follow this rule.") {
29+
t.Errorf("expected always.mdc content in output, got: %q", got)
30+
}
31+
if !strings.Contains(got, "### .cursor/rules/no-frontmatter-fields.mdc") || !strings.Contains(got, "Agent-decided rule body.") {
32+
t.Errorf("expected no-frontmatter-fields.mdc content in output, got: %q", got)
33+
}
34+
}
35+
36+
func TestDiscoverCursorContext_SkipsGlobScopedAndDisabledRules(t *testing.T) {
37+
dir := t.TempDir()
38+
rulesDir := filepath.Join(dir, ".cursor", "rules")
39+
if err := os.MkdirAll(rulesDir, 0755); err != nil {
40+
t.Fatal(err)
41+
}
42+
writeFile(t, rulesDir, "scoped.mdc", "---\nglobs: \"**/*.tsx\"\nalwaysApply: false\n---\n\nOnly for tsx files.\n")
43+
writeFile(t, rulesDir, "disabled.mdc", "---\nalwaysApply: false\n---\n\nManual only.\n")
44+
writeFile(t, rulesDir, "not-a-rule.md", "not an mdc file\n")
45+
46+
got := appcontext.DiscoverCursorContext(dir)
47+
48+
if strings.Contains(got, "scoped.mdc") || strings.Contains(got, "Only for tsx files.") {
49+
t.Errorf("expected glob-scoped rule to be excluded, got: %q", got)
50+
}
51+
if strings.Contains(got, "disabled.mdc") || strings.Contains(got, "Manual only.") {
52+
t.Errorf("expected alwaysApply:false rule to be excluded, got: %q", got)
53+
}
54+
if strings.Contains(got, "not-a-rule") {
55+
t.Errorf("expected non-.mdc file to be ignored, got: %q", got)
56+
}
57+
}
58+
59+
func TestDiscoverCursorContext_MissingDirReturnsEmptyNoError(t *testing.T) {
60+
dir := t.TempDir()
61+
62+
got := appcontext.DiscoverCursorContext(dir)
63+
64+
if got != "" {
65+
t.Errorf("expected empty result for repo with no AGENTS.md/.cursor/rules, got: %q", got)
66+
}
67+
}
68+
69+
func TestDiscoverCursorContext_SkipsFileWithoutFrontmatter(t *testing.T) {
70+
dir := t.TempDir()
71+
rulesDir := filepath.Join(dir, ".cursor", "rules")
72+
if err := os.MkdirAll(rulesDir, 0755); err != nil {
73+
t.Fatal(err)
74+
}
75+
writeFile(t, rulesDir, "plain.mdc", "Just plain text, no frontmatter delimiters.\n")
76+
77+
got := appcontext.DiscoverCursorContext(dir)
78+
79+
if got != "" {
80+
t.Errorf("expected file without frontmatter to be skipped, got: %q", got)
81+
}
82+
}
83+
84+
func writeFile(t *testing.T, dir, name, content string) {
85+
t.Helper()
86+
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0600); err != nil {
87+
t.Fatal(err)
88+
}
89+
}

internal/review/pipeline.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,9 @@ func (p *Pipeline) Run(ctx context.Context, ref PRReference) error {
8080
budget := p.createBudget()
8181
builder := appcontext.NewBuilder(p.tokenizer, budget, p.config)
8282

83-
// Load project context files (CLAUDE.md, etc.)
84-
projectContext := p.loadContextFiles()
83+
// Load project context files (CLAUDE.md, etc.) plus Cursor-native
84+
// context (AGENTS.md, .cursor/rules/*.mdc), if enabled.
85+
projectContext := p.loadProjectContext()
8586

8687
// Fetch existing comments: codestrike's own (trusted, dedup) and user feedback (untrusted)
8788
ownComments, userFeedback := p.fetchExistingCommentsContext(ctx, ref.Number)
@@ -361,6 +362,22 @@ func (p *Pipeline) enrichWithContent(ctx context.Context, files []scm.PullReques
361362
return files
362363
}
363364

365+
func (p *Pipeline) loadProjectContext() string {
366+
var sb strings.Builder
367+
sb.WriteString(p.loadContextFiles())
368+
369+
if p.config.Review.Context.DiscoverCursorRules {
370+
cwd, err := os.Getwd()
371+
if err != nil {
372+
p.logger.Debug().Err(err).Msg("could not determine working directory for cursor rule discovery")
373+
return sb.String()
374+
}
375+
sb.WriteString(appcontext.DiscoverCursorContext(cwd))
376+
}
377+
378+
return sb.String()
379+
}
380+
364381
func (p *Pipeline) loadContextFiles() string {
365382
files := p.config.Review.ContextFiles
366383
if len(files) == 0 {

0 commit comments

Comments
 (0)