Skip to content

Commit bcdaacd

Browse files
committed
feat: Add rules
1 parent 883d546 commit bcdaacd

3 files changed

Lines changed: 288 additions & 0 deletions

File tree

.claude/rules/fastmcp-http.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
---
2+
paths:
3+
- "server/routes/**/*.py"
4+
- "server/tools/**/*.py"
5+
---
6+
7+
# FastMCP + Starlette HTTP conventions
8+
9+
This project uses **FastMCP**. HTTP routes are registered on the
10+
`FastMCP` instance via `@mcp.custom_route`. Never add routes to a separate
11+
FastAPI or Starlette app.
12+
13+
## Route registration pattern
14+
15+
Wrap registration in a `register_*` function that accepts `mcp: FastMCP`. Call it
16+
from `server/main.py`:
17+
18+
```python
19+
from mcp.server.fastmcp import FastMCP
20+
from starlette.requests import Request
21+
from starlette.responses import StreamingResponse
22+
23+
def register_http_routes(mcp: FastMCP) -> None:
24+
25+
@mcp.custom_route("/reindex", methods=["POST"])
26+
async def reindex(request: Request) -> StreamingResponse:
27+
...
28+
```
29+
30+
Same pattern for MCP tools — `register_*_tools(mcp: FastMCP)` in `server/tools/`.
31+
32+
## Request body parsing
33+
34+
Never assume a body exists. Check `content-type` first:
35+
36+
```python
37+
body: dict = {}
38+
if request.headers.get("content-type", "").startswith("application/json"):
39+
raw = await request.body()
40+
if raw:
41+
body = json.loads(raw)
42+
```
43+
44+
## Streaming responses (NDJSON)
45+
46+
All indexing endpoints return `StreamingResponse` with
47+
`media_type="application/x-ndjson"`. Emit one JSON object per line:
48+
49+
- Progress frames: `{"type": "progress", "phase": "...", "current": int, "total": int, "percentage": float, "service": str}`
50+
- Done frame: `{"type": "done", "result": {...}}`
51+
- Error frame: `{"type": "error", "message": str}`
52+
53+
Use an `asyncio.Queue` + `asyncio.create_task` to decouple the async pipeline
54+
from the generator that yields to the client:
55+
56+
```python
57+
async def generate():
58+
queue: asyncio.Queue = asyncio.Queue()
59+
60+
async def run() -> None:
61+
try:
62+
result = await pipeline.index_all(progress_callback=lambda e: queue.put_nowait(e))
63+
await queue.put({"__done__": True, "result": result})
64+
except Exception as exc:
65+
await queue.put(exc)
66+
67+
task = asyncio.create_task(run())
68+
try:
69+
while True:
70+
item = await queue.get()
71+
if isinstance(item, Exception):
72+
yield json.dumps({"type": "error", "message": str(item)}) + "\n"
73+
break
74+
if isinstance(item, dict):
75+
yield json.dumps({"type": "done", "result": item["result"]}) + "\n"
76+
break
77+
yield json.dumps({"type": "progress", **dataclasses.asdict(item)}) + "\n"
78+
finally:
79+
await task
80+
81+
return StreamingResponse(generate(), media_type="application/x-ndjson")
82+
```
83+
84+
## Logging in routes
85+
86+
Log at entry with `%s` args (not f-strings):
87+
88+
```python
89+
logger.info("Reindex started: service=%s force=%s", service or "ALL", force)
90+
```
91+
92+
## MCP tool conventions
93+
94+
- Tool functions registered with `@mcp.tool()` must have descriptive docstrings —
95+
these become the tool description visible to AI clients.
96+
- Access shared state via `server/state.py` getters (`get_store()`, `get_commit_store()`)
97+
rather than importing globals directly.
98+
- Tool implementations live in `server/tools/`; route implementations in `server/routes/`.

.claude/rules/python.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
---
2+
paths:
3+
- "**/*.py"
4+
---
5+
6+
# Python conventions
7+
8+
## Module header
9+
10+
Every module must start with `from __future__ import annotations`. This enables
11+
PEP 563 deferred evaluation — it allows forward references in type hints without
12+
quotes and is already the project-wide convention.
13+
14+
## Type hints
15+
16+
- Annotate every function: parameters and return type. No untyped public functions.
17+
- Python ≥ 3.12 is required — use `X | Y` union syntax, not `Optional[X]` or `Union[X, Y]`.
18+
- Use built-in generics: `list[str]`, `dict[str, int]`, not `List[str]` / `Dict`.
19+
- Use `Protocol` for structural (duck) typing instead of abstract base classes:
20+
21+
```python
22+
from typing import Protocol, runtime_checkable
23+
24+
@runtime_checkable
25+
class EmbeddingProvider(Protocol):
26+
@property
27+
def dimensions(self) -> int: ...
28+
async def embed_batch(self, texts: list[str]) -> list[list[float]]: ...
29+
```
30+
31+
## Logging
32+
33+
Never use `print()`. Use `logging.getLogger(__name__)` and pass format args as
34+
separate arguments (defers string formatting to when the log is actually emitted):
35+
36+
```python
37+
# Correct
38+
logger = logging.getLogger(__name__)
39+
logger.info("Indexing service=%s force=%s", service, force)
40+
41+
# Wrong — eager formatting, print
42+
print(f"indexing {service}")
43+
logger.info(f"Indexing service={service}")
44+
```
45+
46+
## Naming
47+
48+
- Functions and variables: `snake_case`
49+
- Classes: `PascalCase`
50+
- Constants: `UPPER_SNAKE_CASE`
51+
- Private helpers: prefix with `_` (e.g. `_build_bm25_text`)
52+
53+
## String formatting
54+
55+
Use f-strings everywhere except logger calls (see above).
56+
57+
## Dependency management
58+
59+
Use `uv`, not `pip`:
60+
61+
- Install deps: `uv sync` / `uv sync --group dev`
62+
- Run tools: `uv run pytest`, `uv run <script>`
63+
- Never call `pip install` or `python -m pytest` directly.
64+
65+
## Comprehensions over accumulate-in-loop
66+
67+
Prefer list comprehensions. Refactor collect-and-append patterns:
68+
69+
```python
70+
# Wrong
71+
results = []
72+
for sym in symbols:
73+
if sym.language == "python":
74+
results.append(sym.name)
75+
76+
# Correct
77+
results = [sym.name for sym in symbols if sym.language == "python"]
78+
```
79+
80+
Use generators for large sequences to avoid materializing the whole list at once.

.claude/rules/testing.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
---
2+
paths:
3+
- "tests/**/*.py"
4+
---
5+
6+
# Testing conventions
7+
8+
## Framework
9+
10+
Use `pytest`. `asyncio_mode = "auto"` is set globally in `pyproject.toml` — write
11+
`async def test_...()` directly. Never add `@pytest.mark.asyncio`.
12+
13+
## File header
14+
15+
Every test file starts with `from __future__ import annotations`.
16+
17+
## Test naming
18+
19+
Flat functions, not classes. Name tests after the behaviour being verified:
20+
21+
```python
22+
# Correct — describes what the code does
23+
def test_empty_file_returns_no_symbols(): ...
24+
def test_async_function_detected_as_async(): ...
25+
26+
# Wrong — generic or vague
27+
def test_parser(): ...
28+
def test_it_works(): ...
29+
```
30+
31+
## Fixtures
32+
33+
- Use `@pytest.fixture` for reusable setup — they compose cleanly and handle teardown.
34+
- Default scope is function. Use `scope="session"` only for expensive, read-only
35+
resources (e.g. `fixtures_dir` in `conftest.py`).
36+
- Prefer `conftest.py` for fixtures shared across multiple test files.
37+
38+
## Mocking
39+
40+
Use `unittest.mock``AsyncMock`, `MagicMock`, `patch`. Do **not** use `pytest-mock`.
41+
42+
```python
43+
from unittest.mock import ANY, AsyncMock, MagicMock, patch
44+
45+
@pytest.fixture
46+
def mock_pipeline():
47+
pipeline = AsyncMock()
48+
pipeline.index_service.return_value = {"files": 10, "chunks": 50, "skipped": 2}
49+
return pipeline
50+
51+
async def test_reindex(client, mock_pipeline):
52+
with patch("server.routes.reindex.IndexPipeline", return_value=mock_pipeline):
53+
resp = await client.post("/reindex")
54+
assert resp.status_code == 200
55+
```
56+
57+
Use `patch` as a context manager, not a decorator, when patching multiple targets.
58+
59+
## HTTP endpoint tests
60+
61+
Use `httpx.AsyncClient` with `httpx.ASGITransport` — never spin up a real server:
62+
63+
```python
64+
@pytest.fixture
65+
async def client(app):
66+
async with httpx.AsyncClient(
67+
transport=httpx.ASGITransport(app=app),
68+
base_url="http://test",
69+
) as c:
70+
yield c
71+
```
72+
73+
Use `respx` for mocking outbound `httpx` calls (external HTTP the server makes).
74+
75+
## Parser tests
76+
77+
Parse fixtures, then assert by building a name-keyed dict. Check `symbol_type`,
78+
`extras`, and `language` explicitly:
79+
80+
```python
81+
def test_canonical_api_fixture(read_fixture):
82+
src = read_fixture("python/api.py")
83+
syms = PythonParser().parse_file(src, "svc/api.py")
84+
85+
by_name = {s.name: s for s in syms}
86+
assert set(by_name) == {"ChatRequest", "chat", "helper"}
87+
88+
chat = by_name["chat"]
89+
assert chat.symbol_type == "api_route"
90+
assert chat.extras["http_method"] == "POST"
91+
```
92+
93+
Add canonical fixture files under `tests/fixtures/<language>/` for each new language
94+
parser, then snapshot-test via the `read_fixture` fixture from `conftest.py`.
95+
96+
## Parametrize
97+
98+
Use `@pytest.mark.parametrize` for data-driven cases — it avoids duplicating test
99+
logic and makes failures easy to pinpoint:
100+
101+
```python
102+
@pytest.mark.parametrize("source,expected_type", [
103+
(b"def fn(): pass", "function"),
104+
(b"async def fn(): pass", "function"),
105+
(b"class Foo: pass", "class"),
106+
])
107+
def test_symbol_type(source, expected_type):
108+
syms = PythonParser().parse_file(source, "mod.py")
109+
assert syms[0].symbol_type == expected_type
110+
```

0 commit comments

Comments
 (0)