Files
roboco/tests/integration/test_conventions_service.py
T

241 lines
8.0 KiB
Python
Raw Normal View History

"""ConventionsService: cache-by-SHA, fallback, baseline/ambient, scaffold/restore."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from roboco.db.tables import AgentTable, ProjectTable
from roboco.models import AgentRole, AgentStatus, Team
from roboco.services.conventions import get_conventions_service
if TYPE_CHECKING:
from pathlib import Path
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
_AMBIENT_CAP = 1200
_FAKE_PR_NUMBER = 7
class _FakeGit:
"""Captures the scaffold/restore publish call instead of hitting git."""
def __init__(self) -> None:
self.calls: list[dict[str, Any]] = []
async def open_conventions_pr(
self, project_slug: str, *, content: str, **_kwargs: object
) -> dict[str, Any]:
self.calls.append({"slug": project_slug, "content": content})
return {
"branch": "chore/roboco-conventions-scaffold",
"pr_number": _FAKE_PR_NUMBER,
"pr_url": "u",
}
async def _seed_project(
db: AsyncSession, *, head_commit: str, workspace_path: str
) -> ProjectTable:
agent = AgentTable(
id=uuid4(),
name="Dev",
slug=f"be-dev-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="dev",
capabilities=[],
permissions={},
metrics={},
)
db.add(agent)
await db.flush()
project = ProjectTable(
id=uuid4(),
name="C-Proj",
slug=f"c-proj-{uuid4().hex[:8]}",
git_url="https://example.com/r.git",
assigned_cell=Team.BACKEND,
created_by=agent.id,
head_commit=head_commit,
workspace_path=workspace_path,
)
db.add(project)
await db.flush()
return project
async def test_get_map_caches_per_head_sha(
db_session: AsyncSession, tmp_path: Path
) -> None:
project = await _seed_project(
db_session, head_commit="sha1", workspace_path=str(tmp_path)
)
svc = get_conventions_service(db_session)
first = await svc.get_map(project)
# Mutate the workspace AFTER the first call — a cache hit must ignore it.
(tmp_path / "app" / "routers").mkdir(parents=True)
second = await svc.get_map(project)
assert second == first
assert [m.path for m in second.modules] == []
async def test_missing_file_yields_missing_status_and_derived_map(
db_session: AsyncSession, tmp_path: Path
) -> None:
(tmp_path / "app" / "routers").mkdir(parents=True)
project = await _seed_project(
db_session, head_commit="s", workspace_path=str(tmp_path)
)
svc = get_conventions_service(db_session)
mapping = await svc.get_map(project)
assert any(m.path == "app/routers" for m in mapping.modules)
health = await svc.health(project)
assert health.status == "missing"
async def test_corrupt_file_falls_back_to_last_ok(
db_session: AsyncSession, tmp_path: Path
) -> None:
project = await _seed_project(
db_session, head_commit="ok1", workspace_path=str(tmp_path)
)
conv = tmp_path / ".roboco"
conv.mkdir()
(conv / "conventions.yml").write_text(
"modules:\n - path: lib/special\n purpose: special things\n"
)
svc = get_conventions_service(db_session)
ok_map = await svc.get_map(project)
assert any(m.path == "lib/special" for m in ok_map.modules)
project.head_commit = "bad1"
await db_session.flush()
(conv / "conventions.yml").write_text("modules: [unterminated\n")
degraded = await svc.get_map(project)
assert any(m.path == "lib/special" for m in degraded.modules)
health = await svc.health(project)
assert health.status == "degraded"
assert health.last_ok_sha == "ok1"
2026-06-30 08:08:35 +02:00
async def test_health_recovers_after_in_place_repair(
db_session: AsyncSession, tmp_path: Path
) -> None:
"""#132: ``health`` re-reads the LIVE file status — a cached ``degraded``
row must not hide an in-place repair at the same (stale) head key."""
project = await _seed_project(
db_session, head_commit="h", workspace_path=str(tmp_path)
)
conv = tmp_path / ".roboco"
conv.mkdir()
(conv / "conventions.yml").write_text("modules: [unterminated\n")
svc = get_conventions_service(db_session)
await svc.get_map(project) # caches the degraded state
assert (await svc.health(project)).status == "degraded"
# Repair the file in place at the SAME head key.
(conv / "conventions.yml").write_text(
"modules:\n - path: lib/fixed\n purpose: fixed\n"
)
assert (await svc.health(project)).status == "ok"
async def test_get_map_recovers_after_in_place_repair(
db_session: AsyncSession, tmp_path: Path
) -> None:
"""#132: ``get_map`` does not serve a cached ``degraded`` (last-good) map
once the file is repaired in place — it re-derives the fixed map."""
project = await _seed_project(
db_session, head_commit="ok1", workspace_path=str(tmp_path)
)
conv = tmp_path / ".roboco"
conv.mkdir()
(conv / "conventions.yml").write_text(
"modules:\n - path: lib/special\n purpose: special\n"
)
svc = get_conventions_service(db_session)
await svc.get_map(project) # caches ok1 (lib/special)
project.head_commit = "bad1"
await db_session.flush()
(conv / "conventions.yml").write_text("modules: [unterminated\n")
degraded = await svc.get_map(project) # falls back to last_good (lib/special)
assert any(m.path == "lib/special" for m in degraded.modules)
# Repair in place at the same bad1 head with a NEW module.
(conv / "conventions.yml").write_text(
"modules:\n - path: lib/fixed\n purpose: fixed\n"
)
recovered = await svc.get_map(project)
assert any(m.path == "lib/fixed" for m in recovered.modules)
assert not any(m.path == "lib/special" for m in recovered.modules)
async def test_baseline_constraints_include_block_rules(
db_session: AsyncSession, tmp_path: Path
) -> None:
project = await _seed_project(
db_session, head_commit="s", workspace_path=str(tmp_path)
)
constraints = await get_conventions_service(db_session).baseline_constraints(
project
)
assert any("no lint suppressions" in c for c in constraints)
async def test_render_ambient_block_is_bounded(
db_session: AsyncSession, tmp_path: Path
) -> None:
project = await _seed_project(
db_session, head_commit="s", workspace_path=str(tmp_path)
)
block = await get_conventions_service(db_session).render_ambient_block(project)
assert block.startswith("## Architectural Standard")
assert len(block) <= _AMBIENT_CAP
async def test_scaffold_opens_pr_with_rendered_map(
db_session: AsyncSession, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
(tmp_path / "app" / "routers").mkdir(parents=True)
project = await _seed_project(
db_session, head_commit="s", workspace_path=str(tmp_path)
)
fake = _FakeGit()
monkeypatch.setattr(
"roboco.services.conventions.get_git_service", lambda _session: fake
)
result = await get_conventions_service(db_session).scaffold(project)
assert result.created is True
assert result.pr_number == _FAKE_PR_NUMBER
assert fake.calls and "app/routers" in fake.calls[0]["content"]
async def test_restore_uses_last_good_map(
db_session: AsyncSession, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
project = await _seed_project(
db_session, head_commit="ok1", workspace_path=str(tmp_path)
)
conv = tmp_path / ".roboco"
conv.mkdir()
(conv / "conventions.yml").write_text(
"modules:\n - path: lib/special\n purpose: special\n"
)
svc = get_conventions_service(db_session)
await svc.get_map(project) # caches an 'ok' row containing lib/special
fake = _FakeGit()
monkeypatch.setattr(
"roboco.services.conventions.get_git_service", lambda _session: fake
)
result = await svc.restore(project)
assert result.created is True
assert "lib/special" in fake.calls[0]["content"]