Skip to content

Commit 841bcd8

Browse files
committed
fix: preserve .mdc frontmatter, add tests, clean up on switch
1. Rewrite _ensure_mdc_frontmatter() with regex — preserves comments, formatting, and custom keys in existing frontmatter instead of destructively re-serializing via yaml.safe_dump(). Inserts or fixes alwaysApply: true in place. 2. Add 6 focused .mdc frontmatter tests to cursor-agent test file: new file creation, missing frontmatter, preserved custom keys, wrong alwaysApply value, idempotent upserts, removal cleanup. 3. Call remove_context_section() during integration switch Phase 1 — prevents stale SPECKIT markers from being left in the old integration's context file. Also clear context_file from init-options during the metadata reset.
1 parent c1ca2a0 commit 841bcd8

File tree

3 files changed

+120
-21
lines changed

3 files changed

+120
-21
lines changed

src/specify_cli/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2155,6 +2155,7 @@ def integration_switch(
21552155
)
21562156
raise typer.Exit(1)
21572157
removed, skipped = old_manifest.uninstall(project_root, force=force)
2158+
current_integration.remove_context_section(project_root)
21582159
if removed:
21592160
console.print(f" Removed {len(removed)} file(s)")
21602161
if skipped:
@@ -2185,6 +2186,7 @@ def integration_switch(
21852186
opts.pop("integration", None)
21862187
opts.pop("ai", None)
21872188
opts.pop("ai_skills", None)
2189+
opts.pop("context_file", None)
21882190
save_init_options(project_root, opts)
21892191

21902192
# Ensure shared infrastructure is present (safe to run unconditionally;

src/specify_cli/integrations/base.py

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -395,34 +395,51 @@ def _ensure_mdc_frontmatter(content: str) -> str:
395395
396396
If frontmatter is missing, prepend it. If frontmatter exists but
397397
``alwaysApply`` is absent or not ``true``, inject/fix it.
398+
399+
Uses string/regex manipulation to preserve comments and formatting
400+
in existing frontmatter.
398401
"""
399-
import yaml as _yaml
402+
import re as _re
403+
404+
leading_ws = len(content) - len(content.lstrip())
405+
leading = content[:leading_ws]
406+
stripped = content[leading_ws:]
400407

401-
stripped = content.lstrip()
402408
if not stripped.startswith("---"):
403409
return "---\nalwaysApply: true\n---\n\n" + content
404410

405-
# Parse existing frontmatter
406-
end = stripped.find("\n---", 3)
407-
if end == -1:
411+
# Match frontmatter block: ---\n...\n---
412+
match = _re.match(
413+
r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)",
414+
stripped,
415+
_re.DOTALL,
416+
)
417+
if not match:
408418
return "---\nalwaysApply: true\n---\n\n" + content
409419

410-
fm_text = stripped[4:end] # between first --- and closing ---
411-
try:
412-
fm = _yaml.safe_load(fm_text)
413-
except Exception:
414-
fm = None
415-
if not isinstance(fm, dict):
416-
fm = {}
417-
418-
if fm.get("alwaysApply") is True:
419-
return content # already correct
420-
421-
fm["alwaysApply"] = True
422-
new_fm = _yaml.safe_dump(fm, sort_keys=False).strip()
423-
# Reconstruct: frontmatter + rest of file after closing ---
424-
rest = stripped[end + 4:] # after \n---
425-
return f"---\n{new_fm}\n---{rest}"
420+
opening, fm_text, closing, sep, rest = match.groups()
421+
newline = "\r\n" if "\r\n" in opening else "\n"
422+
423+
# Already correct?
424+
if _re.search(
425+
r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text
426+
):
427+
return content
428+
429+
# alwaysApply exists but wrong value — fix in place
430+
if _re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text):
431+
fm_text = _re.sub(
432+
r"(?m)^([ \t]*)alwaysApply[ \t]*:.*$",
433+
r"\1alwaysApply: true",
434+
fm_text,
435+
count=1,
436+
)
437+
elif fm_text.strip():
438+
fm_text = fm_text + newline + "alwaysApply: true"
439+
else:
440+
fm_text = "alwaysApply: true"
441+
442+
return f"{leading}{opening}{fm_text}{closing}{sep}{rest}"
426443

427444
@staticmethod
428445
def _build_context_section(plan_path: str = "") -> str:

tests/integrations/test_integration_cursor_agent.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
"""Tests for CursorAgentIntegration."""
22

3+
from pathlib import Path
4+
5+
from specify_cli.integrations import get_integration
6+
from specify_cli.integrations.manifest import IntegrationManifest
7+
38
from .test_integration_base_skills import SkillsIntegrationTests
49

510

@@ -11,6 +16,81 @@ class TestCursorAgentIntegration(SkillsIntegrationTests):
1116
CONTEXT_FILE = ".cursor/rules/specify-rules.mdc"
1217

1318

19+
class TestCursorMdcFrontmatter:
20+
"""Verify .mdc frontmatter handling in upsert/remove context section."""
21+
22+
def _setup(self, tmp_path: Path):
23+
i = get_integration("cursor-agent")
24+
m = IntegrationManifest("cursor-agent", tmp_path)
25+
return i, m
26+
27+
def test_new_mdc_gets_frontmatter(self, tmp_path):
28+
"""A freshly created .mdc file includes alwaysApply: true."""
29+
i, m = self._setup(tmp_path)
30+
i.setup(tmp_path, m)
31+
ctx = (tmp_path / i.context_file).read_text(encoding="utf-8")
32+
assert ctx.startswith("---\n")
33+
assert "alwaysApply: true" in ctx
34+
35+
def test_existing_mdc_without_frontmatter_gets_it(self, tmp_path):
36+
"""An existing .mdc without frontmatter gets it added."""
37+
i, m = self._setup(tmp_path)
38+
ctx_path = tmp_path / i.context_file
39+
ctx_path.parent.mkdir(parents=True, exist_ok=True)
40+
ctx_path.write_text("# User rules\n", encoding="utf-8")
41+
i.upsert_context_section(tmp_path)
42+
content = ctx_path.read_text(encoding="utf-8")
43+
assert content.lstrip().startswith("---")
44+
assert "alwaysApply: true" in content
45+
assert "# User rules" in content
46+
47+
def test_existing_mdc_with_frontmatter_preserves_it(self, tmp_path):
48+
"""An existing .mdc with custom frontmatter is preserved."""
49+
i, m = self._setup(tmp_path)
50+
ctx_path = tmp_path / i.context_file
51+
ctx_path.parent.mkdir(parents=True, exist_ok=True)
52+
ctx_path.write_text(
53+
"---\nalwaysApply: true\ncustomKey: hello\n---\n\n# Rules\n",
54+
encoding="utf-8",
55+
)
56+
i.upsert_context_section(tmp_path)
57+
content = ctx_path.read_text(encoding="utf-8")
58+
assert "alwaysApply: true" in content
59+
assert "customKey: hello" in content
60+
assert "<!-- SPECKIT START -->" in content
61+
62+
def test_existing_mdc_wrong_alwaysapply_fixed(self, tmp_path):
63+
"""An .mdc with alwaysApply: false gets corrected."""
64+
i, m = self._setup(tmp_path)
65+
ctx_path = tmp_path / i.context_file
66+
ctx_path.parent.mkdir(parents=True, exist_ok=True)
67+
ctx_path.write_text(
68+
"---\nalwaysApply: false\n---\n\n# Rules\n",
69+
encoding="utf-8",
70+
)
71+
i.upsert_context_section(tmp_path)
72+
content = ctx_path.read_text(encoding="utf-8")
73+
assert "alwaysApply: true" in content
74+
assert "alwaysApply: false" not in content
75+
76+
def test_upsert_idempotent_no_duplicate_frontmatter(self, tmp_path):
77+
"""Repeated upserts don't duplicate frontmatter."""
78+
i, m = self._setup(tmp_path)
79+
i.upsert_context_section(tmp_path)
80+
i.upsert_context_section(tmp_path)
81+
content = (tmp_path / i.context_file).read_text(encoding="utf-8")
82+
assert content.count("alwaysApply") == 1
83+
84+
def test_remove_deletes_mdc_with_only_frontmatter(self, tmp_path):
85+
"""Removing the section from a Speckit-only .mdc deletes the file."""
86+
i, m = self._setup(tmp_path)
87+
i.upsert_context_section(tmp_path)
88+
ctx_path = tmp_path / i.context_file
89+
assert ctx_path.exists()
90+
i.remove_context_section(tmp_path)
91+
assert not ctx_path.exists()
92+
93+
1494
class TestCursorAgentAutoPromote:
1595
"""--ai cursor-agent auto-promotes to integration path."""
1696

0 commit comments

Comments
 (0)