Skip to content

Commit 325bbac

Browse files
committed
feat: preservation markers -- SAAR:AUTO-START/END protect manual edits on re-run (OPE-140)
1 parent 96953e6 commit 325bbac

4 files changed

Lines changed: 223 additions & 18 deletions

File tree

AGENTS.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# AGENTS.md -- saar
2+
3+
Generated by [saar](https://getsaar.com). Re-run `saar . --format agents` to update auto-detected sections.
4+
5+
160 functions, 25 classes, 16% async, 89% type-hinted.
6+
7+
**Languages:** python (19 files)
8+
9+
## Coding Conventions
10+
11+
- Functions: `snake_case`
12+
- Classes: `PascalCase`
13+
- Constants: `UPPER_SNAKE_CASE`
14+
15+
Preferred imports:
16+
```
17+
from saar.models import CodebaseDNA
18+
import logging
19+
from pathlib import Path
20+
import re
21+
import tree_sitter_python as tspython
22+
import tree_sitter_javascript as tsjavascript
23+
from tree_sitter import Language, Parser
24+
from collections import Counter
25+
```
26+
27+
## Logging
28+
29+
- Use `logging.getLogger(__name__)` -- never bare `print()`
30+
31+
## Critical Files
32+
33+
These files have the most dependents in the codebase. Understand them before making changes.
34+
35+
- `saar/models.py` (9 dependents)
36+
- `saar/dependency_analyzer.py` (2 dependents)
37+
- `saar/style_analyzer.py` (2 dependents)
38+
- `saar/extractor.py` (2 dependents)
39+
- `saar/formatters/markdown.py` (2 dependents)
40+
- `saar/formatters/claude_md.py` (2 dependents)
41+
- `saar/formatters/cursorrules.py` (2 dependents)
42+
- `saar/formatters/agents_md.py` (2 dependents)
43+
44+
## Error Handling
45+
46+
- Log exceptions before re-raising
47+
48+
## Testing
49+
50+
- Framework: pytest
51+
- Pattern: `test_*.py`
52+
- Fixtures: pytest fixtures
53+
- Mocking: unittest.mock
54+
- Shared fixtures in `conftest.py`
55+
56+
## Project-Specific Rules
57+
58+
*From `CLAUDE.md`*
59+
60+
# CLAUDE.md -- saar
61+
62+
160 functions, 25 classes.
63+
Async adoption: 16%.
64+
Type hint coverage: 89%.
65+
66+
## Coding Conventions
67+
68+
- Use `snake_case` for function names
69+
- Use `PascalCase` for class names
70+
- Use `UPPER_SNAKE_CASE` for constants
71+
72+
Preferred imports:
73+
```
74+
from saar.models import CodebaseDNA
75+
import logging
76+
from pathlib import Path
77+
import re
78+
import tree_sitter_python as tspython
79+
import tree_sitter_javascript as tsjavascript
80+
from tree_sitter import Language, Parser
81+
from collections import Counter
82+
```
83+
84+
## Logging
85+
86+
- Use `logging.getLogger(__name__)` for all logging, never `print()`
87+
88+
## Critical Files
89+
90+
These files have the most dependents -- understand them before editing:
91+
92+
- `saar/models.py` (9 dependents)
93+
- `saar/dependency_analyzer.py` (2 dependents)
94+
- `saar/style_analyzer.py` (2 dependents)
95+
- `saar/extractor.py` (2 dependents)
96+
- `saar/formatters/agents_md.py` (2 dependents)
97+
- `saar/formatters/copilot.py` (2 dependents)
98+
- `saar/formatters/claude_md.py` (2 dependents)
99+
- `saar/formatters/__init__.py` (2 dependents)
100+
101+
## Error Handling
102+
103+
- Always log exceptions before re-raising
104+
105+
## Testing
106+
107+
- Framework: pytest
108+
- Test file pattern: `test_*.py`
109+
- Fixture style: pytest fixtures
110+
- Mock with: unittest.mock
111+
- Shared fixtures live in `conftest.py`
112+
- Run: `pytest tests/ -v`

CLAUDE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
<!-- SAAR:AUTO-START -->
12
# CLAUDE.md -- saar
23

3-
160 functions, 25 classes.
4+
164 functions, 26 classes.
45
Async adoption: 16%.
56
Type hint coverage: 89%.
67

@@ -34,9 +35,9 @@ These files have the most dependents -- understand them before editing:
3435
- `saar/dependency_analyzer.py` (2 dependents)
3536
- `saar/style_analyzer.py` (2 dependents)
3637
- `saar/extractor.py` (2 dependents)
37-
- `saar/formatters/agents_md.py` (2 dependents)
38-
- `saar/formatters/copilot.py` (2 dependents)
3938
- `saar/formatters/claude_md.py` (2 dependents)
39+
- `saar/formatters/copilot.py` (2 dependents)
40+
- `saar/formatters/cursorrules.py` (2 dependents)
4041
- `saar/formatters/__init__.py` (2 dependents)
4142

4243
## Error Handling
@@ -51,3 +52,4 @@ These files have the most dependents -- understand them before editing:
5152
- Mock with: unittest.mock
5253
- Shared fixtures live in `conftest.py`
5354
- Run: `pytest tests/ -v`
55+
<!-- SAAR:AUTO-END -->

saar/cli.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,13 +139,11 @@ def extract(
139139
target = _resolve_output_path(fmt, output, repo_path)
140140

141141
if target is None:
142+
# markdown goes to stdout -- no markers needed
142143
console.print(text)
143-
elif target.exists() and not force:
144-
console.print(f" [yellow]skipped[/yellow] {target} (exists, use --force to overwrite)")
145144
else:
146145
target.parent.mkdir(parents=True, exist_ok=True)
147-
target.write_text(text, encoding="utf-8")
148-
console.print(f" [green]wrote[/green] {target}")
146+
_write_with_markers(target, text, force=force, console=console)
149147

150148
console.print("[bold green]done[/bold green]")
151149

@@ -163,3 +161,51 @@ def _resolve_output_path(
163161

164162
base = output_dir if output_dir else repo_path
165163
return base / filename
164+
165+
166+
_MARKER_START = "<!-- SAAR:AUTO-START -->"
167+
_MARKER_END = "<!-- SAAR:AUTO-END -->"
168+
169+
170+
def _write_with_markers(
171+
target: Path, generated: str, *, force: bool, console: Console
172+
) -> None:
173+
"""Write generated content to target, preserving human edits outside markers.
174+
175+
On first write: wraps content in SAAR:AUTO-START/END markers.
176+
On re-run: replaces only what's between the markers. Content the developer
177+
wrote outside the markers (before or after) is never touched.
178+
179+
--force bypasses preservation and overwrites the whole file. Use it when
180+
you want a clean slate with no manual edits preserved.
181+
"""
182+
wrapped = f"{_MARKER_START}\n{generated.rstrip()}\n{_MARKER_END}\n"
183+
184+
if not target.exists():
185+
target.write_text(wrapped, encoding="utf-8")
186+
console.print(f" [green]wrote[/green] {target}")
187+
return
188+
189+
existing = target.read_text(encoding="utf-8")
190+
191+
if force:
192+
# full overwrite -- discard everything including manual edits
193+
target.write_text(wrapped, encoding="utf-8")
194+
console.print(f" [green]overwrote[/green] {target}")
195+
return
196+
197+
start_idx = existing.find(_MARKER_START)
198+
end_idx = existing.find(_MARKER_END)
199+
200+
if start_idx == -1 or end_idx == -1:
201+
# No markers -- file exists but was written before markers were introduced
202+
# (or is purely hand-written). Treat it like first write: prepend auto block.
203+
target.write_text(wrapped + "\n" + existing, encoding="utf-8")
204+
console.print(f" [green]updated[/green] {target} (prepended auto block)")
205+
return
206+
207+
# Splice: keep everything before the start marker and after the end marker
208+
before = existing[:start_idx]
209+
after = existing[end_idx + len(_MARKER_END):]
210+
target.write_text(before + wrapped + after, encoding="utf-8")
211+
console.print(f" [green]updated[/green] {target} (preserved manual edits)")

tests/test_cli.py

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -82,17 +82,62 @@ def test_verbose_flag(self, tmp_repo: Path):
8282
result = runner.invoke(app, [str(tmp_repo), "--verbose"])
8383
assert result.exit_code == 0
8484

85-
def test_skips_existing_without_force(self, tmp_repo: Path):
86-
"""Should not overwrite existing CLAUDE.md without --force."""
87-
existing = tmp_repo / "CLAUDE.md"
88-
original = existing.read_text()
89-
result = runner.invoke(app, [str(tmp_repo), "--format", "claude"])
85+
def test_skips_existing_without_force(self, tmp_repo: Path, tmp_path: Path):
86+
"""File with markers: re-run updates auto block, does not skip entirely."""
87+
output_dir = tmp_path / "out"
88+
output_dir.mkdir()
89+
# first run creates the file
90+
runner.invoke(app, [str(tmp_repo), "--format", "claude", "-o", str(output_dir)])
91+
first = (output_dir / "CLAUDE.md").read_text()
92+
# second run without --force should update (not skip) because markers exist
93+
result = runner.invoke(app, [str(tmp_repo), "--format", "claude", "-o", str(output_dir)])
9094
assert result.exit_code == 0
91-
assert "skipped" in result.stdout
92-
assert existing.read_text() == original
95+
assert "updated" in result.stdout or "wrote" in result.stdout
9396

94-
def test_force_overwrites(self, tmp_repo: Path):
95-
"""--force should overwrite existing files."""
96-
result = runner.invoke(app, [str(tmp_repo), "--format", "claude", "--force"])
97+
def test_force_overwrites(self, tmp_repo: Path, tmp_path: Path):
98+
"""--force does a clean overwrite discarding any manual edits."""
99+
output_dir = tmp_path / "out"
100+
output_dir.mkdir()
101+
runner.invoke(app, [str(tmp_repo), "--format", "claude", "-o", str(output_dir)])
102+
result = runner.invoke(
103+
app, [str(tmp_repo), "--format", "claude", "--force", "-o", str(output_dir)]
104+
)
97105
assert result.exit_code == 0
98-
assert "wrote" in result.stdout
106+
assert "overwrote" in result.stdout or "wrote" in result.stdout
107+
108+
109+
class TestPreservationMarkers:
110+
"""SAAR:AUTO-START/END markers preserve manual edits on re-run."""
111+
112+
def test_first_write_adds_markers(self, tmp_repo: Path, tmp_path: Path):
113+
output_dir = tmp_path / "out"
114+
output_dir.mkdir()
115+
runner.invoke(app, [str(tmp_repo), "--format", "claude", "-o", str(output_dir)])
116+
content = (output_dir / "CLAUDE.md").read_text()
117+
assert "<!-- SAAR:AUTO-START -->" in content
118+
assert "<!-- SAAR:AUTO-END -->" in content
119+
120+
def test_rerun_preserves_manual_edits(self, tmp_repo: Path, tmp_path: Path):
121+
output_dir = tmp_path / "out"
122+
output_dir.mkdir()
123+
# first run
124+
runner.invoke(app, [str(tmp_repo), "--format", "claude", "-o", str(output_dir)])
125+
# developer adds a custom note after the auto block
126+
target = output_dir / "CLAUDE.md"
127+
target.write_text(target.read_text() + "\n## My Custom Notes\n- Never touch auth.py\n")
128+
# second run
129+
runner.invoke(app, [str(tmp_repo), "--format", "claude", "-o", str(output_dir)])
130+
content = target.read_text()
131+
assert "My Custom Notes" in content
132+
assert "Never touch auth.py" in content
133+
134+
def test_force_discards_manual_edits(self, tmp_repo: Path, tmp_path: Path):
135+
output_dir = tmp_path / "out"
136+
output_dir.mkdir()
137+
runner.invoke(app, [str(tmp_repo), "--format", "claude", "-o", str(output_dir)])
138+
target = output_dir / "CLAUDE.md"
139+
target.write_text(target.read_text() + "\n## My Custom Notes\n- Secret rule\n")
140+
# --force should wipe manual edits
141+
runner.invoke(app, [str(tmp_repo), "--format", "claude", "--force", "-o", str(output_dir)])
142+
content = target.read_text()
143+
assert "Secret rule" not in content

0 commit comments

Comments
 (0)