Skip to content

Commit 39af7f6

Browse files
committed
fix + feat: OPE-143 -- Cursor .mdc format complete and working
Bug fixed: cursor_mdc.py referenced fp.data_fetching which does not exist on FrontendPattern. The correct field is fp.uses_react_query (bool), introduced back in v0.3.x. This caused AttributeError on any repo with TypeScript frontend when generating .mdc files, making the entire cursor-mdc format broken. Fix: replace fp.data_fetching with fp.uses_react_query and generate the rule as a hardcoded best-practice ('use useQuery/useMutation -- never raw fetch') rather than interpolating a field that doesn't exist. What OPE-143 delivers (Cursor .mdc format): --format cursor-mdc generates .cursor/rules/*.mdc files instead of flat .cursorrules. .mdc is Cursor v2 format: each file has YAML frontmatter with glob patterns so rules only load when editing matching files. Better than .cursorrules which loads ALL rules for every file. Output for fastapi-template: .cursor/rules/core.mdc -- alwaysApply: true, verify workflow, package manager .cursor/rules/backend.mdc -- globs: [**/*.py], auth rules WITH reasoning, ORM, testing .cursor/rules/frontend.mdc -- globs: [**/*.ts, **/*.tsx], React/shadcn/TanStack rules .cursor/rules/tests.mdc -- globs: [**/test_*.py], pytest fixtures, parametrize Auto-detection (OPE-141 integration): When .cursor/ dir exists, saar prefers cursor-mdc over flat cursorrules. 'saar extract .' on a Cursor v2 workspace just works. Tests: 496 passing (was 1 failing, now fixed)
1 parent 2bd319f commit 39af7f6

5 files changed

Lines changed: 404 additions & 7 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "saar"
7-
version = "0.5.6"
7+
version = "0.5.7"
88
description = "Extract the essence of your codebase. Auto-generate AGENTS.md, CLAUDE.md, .cursorrules and more."
99
readme = "README.md"
1010
license = "MIT"

saar/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Saar -- extract the essence of your codebase."""
22

3-
__version__ = "0.5.6"
3+
__version__ = "0.5.7"

saar/cli.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ class OutputFormat(str, Enum):
5454
markdown = "markdown"
5555
claude = "claude"
5656
cursorrules = "cursorrules"
57+
cursor_mdc = "cursor-mdc" # Cursor v2 -- .cursor/rules/*.mdc with glob loading
5758
copilot = "copilot"
5859
all = "all"
5960

@@ -341,6 +342,7 @@ def extract(
341342
OutputFormat.agents,
342343
OutputFormat.claude,
343344
OutputFormat.cursorrules,
345+
OutputFormat.cursor_mdc,
344346
OutputFormat.copilot,
345347
]
346348
else:
@@ -407,6 +409,11 @@ def extract(
407409
from saar.formatters import render
408410

409411
for fmt in target_formats:
412+
# cursor_mdc is a multi-file format -- special handling
413+
if fmt == OutputFormat.cursor_mdc:
414+
_write_cursor_mdc(dna, output or repo_path, force=force, console=console)
415+
continue
416+
410417
text = render(dna, fmt.value, budget=effective_budget)
411418
target = _resolve_output_path(fmt, output, repo_path)
412419

@@ -568,6 +575,51 @@ def _show_detection_summary(dna, console, no_interview: bool) -> bool:
568575
return True
569576

570577

578+
def _write_cursor_mdc(dna, base_dir: Path, *, force: bool, console: Console) -> None:
579+
"""Write .cursor/rules/*.mdc files for Cursor v2 format (OPE-143).
580+
581+
Why separate from _write_with_markers:
582+
.mdc files are generated in a directory, not a single file.
583+
They don't use SAAR markers -- they're fully regenerated each time
584+
because Cursor manages them as a set. Existing .mdc files written
585+
by saar are always overwritten; hand-crafted ones are skipped (OPE-181).
586+
"""
587+
from saar.formatters.cursor_mdc import render_cursor_mdc
588+
589+
rules_dir = base_dir / ".cursor" / "rules"
590+
rules_dir.mkdir(parents=True, exist_ok=True)
591+
592+
mdc_files = render_cursor_mdc(dna)
593+
if not mdc_files:
594+
return
595+
596+
written = []
597+
skipped = []
598+
for filename, content in mdc_files.items():
599+
target = rules_dir / filename
600+
if target.exists() and not force:
601+
# check if it's saar-generated (has our frontmatter signature)
602+
existing = target.read_text(encoding="utf-8")
603+
is_ours = "alwaysApply:" in existing # saar always writes this field
604+
if not is_ours:
605+
skipped.append(filename)
606+
continue
607+
target.write_text(content, encoding="utf-8")
608+
written.append(filename)
609+
610+
if written:
611+
files_str = ", ".join(written)
612+
console.print(
613+
f" [green]wrote[/green] .cursor/rules/ "
614+
f"[dim]({files_str})[/dim]"
615+
)
616+
if skipped:
617+
console.print(
618+
f" [yellow]skipped[/yellow] .cursor/rules/{skipped[0]} "
619+
f"[dim](hand-crafted — use --force to overwrite)[/dim]"
620+
)
621+
622+
571623
def _detect_ai_tools(repo_path: Path) -> list[OutputFormat]:
572624
"""Detect which AI tools are present in the repo and return matching formats.
573625
@@ -585,9 +637,11 @@ def _detect_ai_tools(repo_path: Path) -> list[OutputFormat]:
585637
"""
586638
detected: list[OutputFormat] = []
587639

588-
# Cursor -- check for .cursor/ dir (Cursor v2 workspace) or legacy .cursorrules
589-
if (repo_path / ".cursor").is_dir() or (repo_path / ".cursorrules").exists():
590-
detected.append(OutputFormat.cursorrules)
640+
# Cursor -- prefer .mdc (v2) when .cursor/ dir exists, fall back to flat .cursorrules
641+
if (repo_path / ".cursor").is_dir():
642+
detected.append(OutputFormat.cursor_mdc) # .cursor/rules/*.mdc files
643+
elif (repo_path / ".cursorrules").exists():
644+
detected.append(OutputFormat.cursorrules) # legacy flat file
591645

592646
# Claude Code -- CLAUDE.md already present means user wants it maintained
593647
if (repo_path / "CLAUDE.md").exists():

saar/formatters/cursor_mdc.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
"""Cursor .mdc rule formatter (OPE-143).
2+
3+
Generates .cursor/rules/*.mdc files with YAML frontmatter and glob-based
4+
conditional loading. This is the Cursor v2 format, strictly better than
5+
flat .cursorrules because rules load only when editing matching files.
6+
7+
Why this matters:
8+
.cursorrules loads ALL rules for every file.
9+
.mdc loads ONLY the rules relevant to the file being edited.
10+
A Python backend rule never loads when editing a React component.
11+
Shorter context = more precise AI behavior.
12+
13+
.mdc format:
14+
---
15+
description: Short summary shown in Cursor UI
16+
globs: ["**/*.py"] # only load for Python files
17+
alwaysApply: false # true = load regardless of file
18+
---
19+
# Rule content here (markdown)
20+
21+
Output: dict[filename, content] where filename is relative to .cursor/rules/
22+
e.g. {"backend.mdc": "---\\n...", "frontend.mdc": "---\\n..."}
23+
24+
The CLI writes each entry to .cursor/rules/<filename>.
25+
"""
26+
from __future__ import annotations
27+
28+
from saar.models import CodebaseDNA
29+
30+
31+
def _frontmatter(description: str, globs: list[str], always: bool = False) -> str:
32+
"""Build YAML frontmatter block for a .mdc file."""
33+
lines = ["---", f"description: {description}"]
34+
if globs:
35+
# Cursor expects a JSON array in the globs field
36+
glob_str = ", ".join(f'"{g}"' for g in globs)
37+
lines.append(f"globs: [{glob_str}]")
38+
lines.append(f"alwaysApply: {'true' if always else 'false'}")
39+
lines.append("---")
40+
return "\n".join(lines)
41+
42+
43+
def render_cursor_mdc(dna: CodebaseDNA) -> dict[str, str]:
44+
"""Render DNA as a set of .mdc rule files for .cursor/rules/.
45+
46+
Returns a dict mapping filename -> file content.
47+
Each .mdc file targets a specific part of the codebase via globs.
48+
"""
49+
files: dict[str, str] = {}
50+
51+
fp = dna.frontend_patterns
52+
has_python = dna.language_distribution.get("python", 0) > 0
53+
has_ts = dna.language_distribution.get("typescript", 0) > 0
54+
has_js = dna.language_distribution.get("javascript", 0) > 0
55+
56+
# ── core.mdc -- always loaded, project-level rules ────────────────────
57+
core_lines: list[str] = []
58+
59+
if dna.detected_framework:
60+
core_lines.append(f"This is a **{dna.detected_framework}** project.\n")
61+
62+
# verify workflow -- always relevant
63+
if dna.verify_workflow:
64+
core_lines.append("## How to Verify Changes\n")
65+
core_lines.append(dna.verify_workflow)
66+
core_lines.append("\nRun these before considering any change done.\n")
67+
68+
# package manager
69+
if fp and fp.package_manager:
70+
core_lines.append("## Package Manager\n")
71+
core_lines.append(
72+
f"- Always use `{fp.package_manager}` — never npm or yarn"
73+
)
74+
75+
# deep rules that are always relevant (never_do, auth)
76+
deep_rules = getattr(dna, "deep_rules", [])
77+
never_do = [r for r in deep_rules if r.get("category") == "never_do"]
78+
if never_do:
79+
core_lines.append("\n## Never Do\n")
80+
for r in never_do[:5]:
81+
core_lines.append(f"- {r['text']}")
82+
83+
# team rules
84+
if dna.team_rules:
85+
core_lines.append("\n## Project-Specific Rules\n")
86+
core_lines.append(dna.team_rules.strip())
87+
88+
if core_lines:
89+
content = _frontmatter(
90+
f"{dna.repo_name} project rules",
91+
globs=[],
92+
always=True,
93+
)
94+
content += "\n\n" + "\n".join(core_lines).strip() + "\n"
95+
files["core.mdc"] = content
96+
97+
# ── backend.mdc -- Python files only ─────────────────────────────────
98+
if has_python:
99+
backend_lines: list[str] = []
100+
backend_globs = ["**/*.py"]
101+
102+
# auth deep rules
103+
auth_deep = [r for r in deep_rules if r.get("category") == "auth"]
104+
if auth_deep:
105+
backend_lines.append("## Auth\n")
106+
for r in auth_deep[:3]:
107+
backend_lines.append(f"- {r['text']}")
108+
109+
# exception rules
110+
exc_deep = [r for r in deep_rules if r.get("category") == "exceptions"]
111+
ep = dna.error_patterns
112+
if exc_deep or ep.exception_classes:
113+
backend_lines.append("\n## Error Handling\n")
114+
for r in exc_deep[:2]:
115+
backend_lines.append(f"- {r['text']}")
116+
if ep.exception_classes and not exc_deep:
117+
all_exc = ep.exception_classes
118+
if len(all_exc) <= 10:
119+
backend_lines.append(
120+
f"- Use domain exceptions: `{', '.join(all_exc)}`"
121+
)
122+
else:
123+
top = all_exc[:8]
124+
backend_lines.append(
125+
f"- Use domain exceptions ({len(all_exc)} total). "
126+
f"Top: `{', '.join(top)}`"
127+
)
128+
if ep.http_exception_usage:
129+
backend_lines.append("- Raise `HTTPException` for API errors")
130+
if ep.logging_on_error:
131+
backend_lines.append("- Log before re-raising")
132+
133+
# database
134+
db = dna.database_patterns
135+
if db.orm_used:
136+
backend_lines.append("\n## Database\n")
137+
backend_lines.append(f"- ORM: {db.orm_used}")
138+
if db.id_type != "unknown":
139+
backend_lines.append(f"- ID type: `{db.id_type}`")
140+
if db.has_rls:
141+
backend_lines.append("- RLS enabled — always respect row-level policies")
142+
143+
# naming
144+
nc = dna.naming_conventions
145+
if nc.function_style != "unknown":
146+
backend_lines.append("\n## Naming\n")
147+
backend_lines.append(f"- Functions: `{nc.function_style}`")
148+
if nc.class_style != "unknown":
149+
backend_lines.append(f"- Classes: `{nc.class_style}`")
150+
151+
# testing deep rules
152+
test_deep = [r for r in deep_rules if r.get("category") == "testing"]
153+
tp = dna.test_patterns
154+
if tp.framework or test_deep:
155+
backend_lines.append("\n## Testing\n")
156+
if tp.framework:
157+
backend_lines.append(f"- Framework: `{tp.framework}` — pattern `{tp.test_file_pattern}`")
158+
for r in test_deep[:2]:
159+
backend_lines.append(f"- {r['text']}")
160+
161+
# logging
162+
lp = dna.logging_patterns
163+
if lp.logger_import:
164+
backend_lines.append("\n## Logging\n")
165+
backend_lines.append(f"- Use `{lp.logger_import}` — never bare `print()`")
166+
167+
if backend_lines:
168+
content = _frontmatter(
169+
"Python backend rules",
170+
globs=backend_globs,
171+
)
172+
content += "\n\n" + "\n".join(backend_lines).strip() + "\n"
173+
files["backend.mdc"] = content
174+
175+
# ── frontend.mdc -- TypeScript/JavaScript files ───────────────────────
176+
if (has_ts or has_js) and fp:
177+
fe_lines: list[str] = []
178+
fe_globs = []
179+
if has_ts:
180+
fe_globs += ["**/*.ts", "**/*.tsx"]
181+
if has_js:
182+
fe_globs += ["**/*.js", "**/*.jsx"]
183+
184+
if fp.framework:
185+
fe_lines.append("## Framework\n")
186+
fe_lines.append(f"- {fp.framework}")
187+
if fp.component_library:
188+
fe_lines.append(
189+
f"- Use `{fp.component_library}` components over custom ones"
190+
)
191+
if fp.state_management:
192+
fe_lines.append(f"- State: {fp.state_management}")
193+
if fp.uses_react_query:
194+
fe_lines.append(
195+
"- Data fetching: use `useQuery`/`useMutation` — never raw `fetch` in useEffect"
196+
)
197+
198+
# naming deep rules for frontend
199+
naming_deep = [r for r in deep_rules if r.get("category") == "naming"]
200+
if naming_deep:
201+
fe_lines.append("\n## Naming\n")
202+
for r in naming_deep[:3]:
203+
fe_lines.append(f"- {r['text']}")
204+
205+
# frontend testing
206+
if fp.test_framework:
207+
fe_lines.append("\n## Testing\n")
208+
fe_lines.append(f"- Framework: `{fp.test_framework}`")
209+
210+
if fe_lines:
211+
content = _frontmatter(
212+
"Frontend TypeScript/React rules",
213+
globs=fe_globs,
214+
)
215+
content += "\n\n" + "\n".join(fe_lines).strip() + "\n"
216+
files["frontend.mdc"] = content
217+
218+
# ── tests.mdc -- test files only ──────────────────────────────────────
219+
test_globs: list[str] = []
220+
if has_python:
221+
test_globs.append("**/test_*.py")
222+
test_globs.append("**/*_test.py")
223+
if has_ts or has_js:
224+
test_globs.append("**/*.test.ts")
225+
test_globs.append("**/*.test.tsx")
226+
test_globs.append("**/*.spec.ts")
227+
228+
test_deep = [r for r in deep_rules if r.get("category") == "testing"]
229+
tp = dna.test_patterns
230+
231+
if test_globs and (test_deep or tp.framework):
232+
test_lines: list[str] = ["## Testing Rules\n"]
233+
if tp.framework:
234+
test_lines.append(f"- Framework: `{tp.framework}`")
235+
if tp.has_conftest:
236+
test_lines.append("- Shared fixtures in `conftest.py` — use pytest fixtures, not setUp/tearDown")
237+
for r in test_deep[:4]:
238+
if "conftest" not in r["text"].lower() or not tp.has_conftest:
239+
test_lines.append(f"- {r['text']}")
240+
if tp.mock_library:
241+
test_lines.append(f"- Mocking: `{tp.mock_library}`")
242+
243+
content = _frontmatter(
244+
"Test file rules",
245+
globs=test_globs,
246+
)
247+
content += "\n\n" + "\n".join(test_lines).strip() + "\n"
248+
files["tests.mdc"] = content
249+
250+
return files

0 commit comments

Comments
 (0)