mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Feature/architectural conventions standard (#243)
* feat(conventions): standard schema models + effective-map merge * feat(conventions): tree-sitter Python classifier + placement checks * feat(conventions): TS classifier, hygiene/custom checks, runner + CLI * feat(conventions): ROBOCO_CONVENTIONS_ENABLED flag + cache table + migration * feat(conventions): repo auto-scan + scaffold draft renderer * feat(conventions): ConventionsService (cache/baseline/ambient/scaffold/restore) * feat(conventions): auto-scaffold on project registration (flag-gated) * feat(conventions): TaskDescription.constraints + auto-baseline attach * feat(conventions): ambient architecture-map injection at spawn * test(conventions): subprocess CLI smoke for the agent-image entrypoint * feat(conventions): block i_am_done on block-level convention violations * feat(conventions): block pr_pass on unresolved convention violations * feat(conventions): surface convention findings into QA evidence * docs(prompts): convention awareness for PO/Intake/Dev/QA/PR-reviewer * feat(conventions): panel Conventions tab + flag toggle + parity * test(conventions): end-to-end block, fix, and waiver through the gate * refactor(conventions): extract pr_pass guards to keep pr_gate under the gate * style(conventions): format the baseline-constraints attach in task.create * test(conventions): type-annotate test helpers for the full mypy gate * build(conventions): ignore types-PyYAML in deptry (mypy-only type stub) * docs(conventions): document the standard in CLAUDE.md + PM prompt awareness * fix(conventions): baseline constraints are non-suppressible (dedup-append) * feat(conventions): scaffold on first workspace clone (threaded workspace) * feat(conventions): multi-project ambient map for PO/Intake (per-product) * feat(conventions): persist findings + violations-feed route (migration 044) * feat(conventions): panel violations feed in the Conventions tab * test(conventions): intake-spawn mock accepts the ambient layer kwarg * fix(docker): ollama-init best-effort pull, gate startup on cached models present A degraded/slow ollama registry made the model manifest re-check fail under set -e, so ollama-init exited 1 and blocked the orchestrator's service_completed_successfully gate — taking the whole stack down even though both models were already cached. Pulls are now best-effort; success is gated on the models being present, so a flaky registry can't down a cached deployment. * refactor(content): drop dead TaskDescription.with_baseline_constraints The structured baseline-merge helper had zero production callers. Project-task baseline constraints are attached by the wired string backstop (TaskService._attach_baseline_constraints), and a real task is free-form prose that cannot form a valid TaskDescription (requires a non-trivial objective + non-empty the_work), so the helper was unreachable from any live path — a leftover from the structured-merge -> string-append design pivot. Removing it leaves a single enforcement path. The constraints field itself stays: it is a member of the well-formed-spec schema (Objective / What This Builds / The Work / Notes / Constraints / Acceptance Criteria), rendered by render_markdown and unit-tested. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
"""conventions_ambient_layer: flag-gated, project-aware ambient-block resolver."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
from roboco.agents.factories._base import conventions_ambient_layer
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import AgentTable, ProjectTable
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
async def _seed_project(db: AsyncSession) -> 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,
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
return project
|
||||
|
||||
|
||||
async def test_ambient_block_when_flag_on(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "conventions_enabled", True)
|
||||
project = await _seed_project(db_session)
|
||||
block = await conventions_ambient_layer(db_session, [project])
|
||||
assert block is not None
|
||||
assert block.startswith("## Architectural Standard")
|
||||
|
||||
|
||||
async def test_merges_multiple_projects_with_slug_headers(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "conventions_enabled", True)
|
||||
p1 = await _seed_project(db_session)
|
||||
p2 = await _seed_project(db_session)
|
||||
block = await conventions_ambient_layer(db_session, [p1, p2])
|
||||
assert block is not None
|
||||
assert f"### Project `{p1.slug}`" in block
|
||||
assert f"### Project `{p2.slug}`" in block
|
||||
|
||||
|
||||
async def test_none_when_flag_off(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "conventions_enabled", False)
|
||||
project = await _seed_project(db_session)
|
||||
assert await conventions_ambient_layer(db_session, [project]) is None
|
||||
|
||||
|
||||
async def test_none_when_no_project(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "conventions_enabled", True)
|
||||
assert await conventions_ambient_layer(db_session, []) is None
|
||||
@@ -0,0 +1,106 @@
|
||||
"""The project_conventions_cache table round-trips JSONB and enforces uniqueness."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from roboco.db.tables import (
|
||||
AgentTable,
|
||||
ProjectConventionsCacheTable,
|
||||
ProjectTable,
|
||||
)
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
async def _seed_project(db: AsyncSession) -> 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,
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
return project
|
||||
|
||||
|
||||
async def test_cache_row_round_trips_jsonb(db_session: AsyncSession) -> None:
|
||||
project = await _seed_project(db_session)
|
||||
row = ProjectConventionsCacheTable(
|
||||
id=uuid4(),
|
||||
project_id=project.id,
|
||||
commit_sha="abc1234",
|
||||
effective_map={
|
||||
"version": 1,
|
||||
"rules": {
|
||||
"no_models_in_routers": {
|
||||
"name": "no_models_in_routers",
|
||||
"level": "block",
|
||||
}
|
||||
},
|
||||
},
|
||||
status="ok",
|
||||
)
|
||||
db_session.add(row)
|
||||
await db_session.flush()
|
||||
await db_session.refresh(row)
|
||||
|
||||
fetched = (
|
||||
await db_session.execute(
|
||||
select(ProjectConventionsCacheTable).where(
|
||||
ProjectConventionsCacheTable.project_id == project.id
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
assert fetched.status == "ok"
|
||||
assert fetched.effective_map["rules"]["no_models_in_routers"]["level"] == "block"
|
||||
assert fetched.derived_at is not None
|
||||
|
||||
|
||||
async def test_project_sha_uniqueness_is_enforced(db_session: AsyncSession) -> None:
|
||||
project = await _seed_project(db_session)
|
||||
db_session.add(
|
||||
ProjectConventionsCacheTable(
|
||||
id=uuid4(),
|
||||
project_id=project.id,
|
||||
commit_sha="dup",
|
||||
effective_map={},
|
||||
status="ok",
|
||||
)
|
||||
)
|
||||
await db_session.flush()
|
||||
db_session.add(
|
||||
ProjectConventionsCacheTable(
|
||||
id=uuid4(),
|
||||
project_id=project.id,
|
||||
commit_sha="dup",
|
||||
effective_map={},
|
||||
status="ok",
|
||||
)
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
await db_session.flush()
|
||||
@@ -0,0 +1,91 @@
|
||||
"""End-to-end: the real validator subprocess feeds the real gate decision.
|
||||
|
||||
Exercises the whole enforcement path against a real repo on disk — effective
|
||||
map (auto-derived ⊕ committed file), tree-sitter placement, waiver filtering,
|
||||
and the gateway's block/pass decision — without the orchestrator plumbing:
|
||||
|
||||
1. a model defined in a router blocks the submit with the offending file:line;
|
||||
2. after the model moves to ``app/models``, the submit passes;
|
||||
3. a committed waiver lets a deliberately-kept model through the gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from roboco.services.gateway.choreographer import Choreographer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_MODEL_SRC = (
|
||||
"from pydantic import BaseModel\nclass UserCreate(BaseModel):\n x: int\n"
|
||||
)
|
||||
_FORBID_MODEL = (
|
||||
"modules:\n - path: app/routers\n purpose: routes\n forbidden: [model]\n"
|
||||
)
|
||||
|
||||
|
||||
def _run_validator(root: Path, files: list[str]) -> dict[str, Any]:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"roboco.conventions",
|
||||
"check",
|
||||
"--root",
|
||||
str(root),
|
||||
"--files",
|
||||
*files,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
findings = [json.loads(line) for line in proc.stdout.splitlines() if line.strip()]
|
||||
return {"findings": findings, "could_not_run": proc.returncode != 0}
|
||||
|
||||
|
||||
def _write(root: Path, rel: str, content: str) -> None:
|
||||
path = root / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
|
||||
|
||||
def test_block_then_fix_then_waiver(tmp_path: Path) -> None:
|
||||
_write(tmp_path, ".roboco/conventions.yml", _FORBID_MODEL)
|
||||
_write(tmp_path, "app/routers/users.py", _MODEL_SRC)
|
||||
|
||||
# 1. A Pydantic model in the router blocks, naming the offending file:line.
|
||||
blocked = _run_validator(tmp_path, ["app/routers/users.py"])
|
||||
rejection = Choreographer._conventions_rejection(blocked, {})
|
||||
assert rejection is not None
|
||||
assert "app/routers/users.py:2" in rejection.as_dict()["remediate"]
|
||||
|
||||
# 2. Move the model to app/models; the router now only holds a route → passes.
|
||||
_write(
|
||||
tmp_path,
|
||||
"app/routers/users.py",
|
||||
"@router.get('/users')\ndef list_users():\n return []\n",
|
||||
)
|
||||
_write(tmp_path, "app/models/user.py", _MODEL_SRC)
|
||||
fixed = _run_validator(tmp_path, ["app/routers/users.py", "app/models/user.py"])
|
||||
assert Choreographer._conventions_rejection(fixed, {}) is None
|
||||
|
||||
# 3. A deliberately-kept model in a router blocks — until a committed waiver
|
||||
# (reviewed in the PR) suppresses exactly that finding.
|
||||
_write(tmp_path, "app/routers/legacy.py", _MODEL_SRC)
|
||||
still_blocked = _run_validator(tmp_path, ["app/routers/legacy.py"])
|
||||
assert Choreographer._conventions_rejection(still_blocked, {}) is not None
|
||||
|
||||
_write(
|
||||
tmp_path,
|
||||
".roboco/conventions.yml",
|
||||
_FORBID_MODEL + "waivers:\n - path: app/routers/legacy.py\n"
|
||||
" rule: no_models_in_routers\n reason: extraction tracked\n",
|
||||
)
|
||||
waived = _run_validator(tmp_path, ["app/routers/legacy.py"])
|
||||
assert Choreographer._conventions_rejection(waived, {}) is None
|
||||
@@ -0,0 +1,160 @@
|
||||
"""Convention-findings persistence + the violations-feed route."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from roboco.api.deps import get_agent_context, get_db
|
||||
from roboco.api.routes.project import router as project_router
|
||||
from roboco.db.tables import AgentTable, ProjectTable
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
from roboco.models.permissions import AgentContext
|
||||
from roboco.services.conventions import get_conventions_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
_HDR = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "main_pm"}
|
||||
|
||||
_FINDINGS = [
|
||||
{
|
||||
"file": "app/routers/u.py",
|
||||
"line": 2,
|
||||
"kind": "model",
|
||||
"rule": "no_models_in_routers",
|
||||
"level": "block",
|
||||
"message": "model in router",
|
||||
"fix_hint": "move it",
|
||||
},
|
||||
{
|
||||
"file": "app/routers/u.py",
|
||||
"line": 9,
|
||||
"kind": None,
|
||||
"rule": "no_inline_comments",
|
||||
"level": "warn",
|
||||
"message": "inline comment",
|
||||
"fix_hint": "remove",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
async def _seed_project(db: AsyncSession) -> 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,
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
return project
|
||||
|
||||
|
||||
async def test_record_then_recent_findings(db_session: AsyncSession) -> None:
|
||||
project = await _seed_project(db_session)
|
||||
svc = get_conventions_service(db_session)
|
||||
pid = UUID(str(project.id))
|
||||
await svc.record_findings(pid, uuid4(), _FINDINGS)
|
||||
recent = await svc.recent_findings(pid)
|
||||
assert len(recent) == len(_FINDINGS)
|
||||
rules = {f["rule"] for f in recent}
|
||||
assert rules == {"no_models_in_routers", "no_inline_comments"}
|
||||
assert all(f["detected_at"] for f in recent)
|
||||
|
||||
|
||||
async def test_record_replaces_prior_findings_for_task(
|
||||
db_session: AsyncSession,
|
||||
) -> None:
|
||||
project = await _seed_project(db_session)
|
||||
svc = get_conventions_service(db_session)
|
||||
pid = UUID(str(project.id))
|
||||
task = uuid4()
|
||||
await svc.record_findings(pid, task, _FINDINGS)
|
||||
await svc.record_findings(pid, task, _FINDINGS[:1]) # latest wins
|
||||
recent = await svc.recent_findings(pid)
|
||||
assert len(recent) == len(_FINDINGS[:1])
|
||||
assert recent[0]["rule"] == "no_models_in_routers"
|
||||
|
||||
|
||||
async def test_record_skips_malformed_entries(db_session: AsyncSession) -> None:
|
||||
project = await _seed_project(db_session)
|
||||
svc = get_conventions_service(db_session)
|
||||
pid = UUID(str(project.id))
|
||||
# a could_not_run entry (no file/rule) must not be recorded
|
||||
await svc.record_findings(pid, uuid4(), [{"could_not_run": True, "reason": "x"}])
|
||||
assert await svc.recent_findings(pid) == []
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
|
||||
agent = AgentTable(
|
||||
id=uuid4(),
|
||||
name="MainPM",
|
||||
slug=f"main-pm-{uuid4().hex[:8]}",
|
||||
role=AgentRole.MAIN_PM,
|
||||
team=None,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="pm",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
db_session.add(agent)
|
||||
await db_session.flush()
|
||||
app = FastAPI()
|
||||
app.include_router(project_router, prefix="/api/projects")
|
||||
|
||||
async def _override_db() -> AsyncIterator[AsyncSession]:
|
||||
yield db_session
|
||||
|
||||
async def _override_agent() -> AgentContext:
|
||||
return AgentContext(
|
||||
agent_id=cast("UUID", agent.id), role=AgentRole.MAIN_PM, team=None
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
app.dependency_overrides[get_agent_context] = _override_agent
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
async def test_findings_route_returns_recorded(
|
||||
db_session: AsyncSession, client: AsyncClient
|
||||
) -> None:
|
||||
project = await _seed_project(db_session)
|
||||
pid = UUID(str(project.id))
|
||||
await get_conventions_service(db_session).record_findings(pid, uuid4(), _FINDINGS)
|
||||
resp = await client.get(
|
||||
f"/api/projects/{project.id}/conventions/findings", headers=_HDR
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
body = resp.json()
|
||||
assert len(body) == len(_FINDINGS)
|
||||
assert {f["rule"] for f in body} == {"no_models_in_routers", "no_inline_comments"}
|
||||
@@ -0,0 +1,187 @@
|
||||
"""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"
|
||||
|
||||
|
||||
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 models in routers" 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"]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""GitService.open_conventions_pr commits the file locally; PR is best-effort."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
from roboco.db.tables import AgentTable, ProjectTable
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
from roboco.services.git import get_git_service
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
_SCAFFOLD_BRANCH = "chore/roboco-conventions-scaffold"
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> None:
|
||||
subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True)
|
||||
|
||||
|
||||
async def _seed_project(db: AsyncSession, 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="G-Proj",
|
||||
slug=f"g-proj-{uuid4().hex[:8]}",
|
||||
git_url="https://example.com/r.git",
|
||||
default_branch="master",
|
||||
assigned_cell=Team.BACKEND,
|
||||
created_by=agent.id,
|
||||
workspace_path=workspace_path,
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
return project
|
||||
|
||||
|
||||
async def test_open_conventions_pr_commits_locally_without_remote(
|
||||
db_session: AsyncSession, tmp_path: Path
|
||||
) -> None:
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
_git(repo, "init", "-b", "master")
|
||||
_git(repo, "config", "user.email", "t@example.com")
|
||||
_git(repo, "config", "user.name", "T")
|
||||
_git(repo, "config", "commit.gpgsign", "false")
|
||||
(repo / "README.md").write_text("# r\n")
|
||||
_git(repo, "add", "README.md")
|
||||
_git(repo, "commit", "-m", "init")
|
||||
|
||||
project = await _seed_project(db_session, str(repo))
|
||||
git = get_git_service(db_session)
|
||||
result = await git.open_conventions_pr(
|
||||
project.slug,
|
||||
content="version: 1\n",
|
||||
title="scaffold",
|
||||
body="b",
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["pr_number"] is None # no git token / remote → PR not opened
|
||||
show = subprocess.run(
|
||||
["git", "show", f"{_SCAFFOLD_BRANCH}:.roboco/conventions.yml"],
|
||||
cwd=repo,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert show.returncode == 0
|
||||
assert show.stdout == "version: 1\n"
|
||||
|
||||
|
||||
async def test_open_conventions_pr_returns_none_without_workspace(
|
||||
db_session: AsyncSession, tmp_path: Path
|
||||
) -> None:
|
||||
project = await _seed_project(db_session, str(tmp_path / "does-not-exist"))
|
||||
git = get_git_service(db_session)
|
||||
result = await git.open_conventions_pr(
|
||||
project.slug,
|
||||
content="version: 1\n",
|
||||
title="t",
|
||||
body="b",
|
||||
)
|
||||
assert result is None
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Conventions API routes: GET map+health, PUT commit-back, POST restore."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from roboco.api.deps import get_agent_context, get_db
|
||||
from roboco.api.routes.project import router as project_router
|
||||
from roboco.db.tables import AgentTable
|
||||
from roboco.models import AgentRole, AgentStatus
|
||||
from roboco.models.permissions import AgentContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
_HDR = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "main_pm"}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]:
|
||||
agent = AgentTable(
|
||||
id=uuid4(),
|
||||
name="MainPM",
|
||||
slug=f"main-pm-{uuid4().hex[:8]}",
|
||||
role=AgentRole.MAIN_PM,
|
||||
team=None,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="pm",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
db_session.add(agent)
|
||||
await db_session.flush()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(project_router, prefix="/api/projects")
|
||||
|
||||
async def _override_db() -> AsyncIterator[AsyncSession]:
|
||||
yield db_session
|
||||
|
||||
async def _override_agent() -> AgentContext:
|
||||
return AgentContext(
|
||||
agent_id=cast("UUID", agent.id), role=AgentRole.MAIN_PM, team=None
|
||||
)
|
||||
|
||||
app.dependency_overrides[get_db] = _override_db
|
||||
app.dependency_overrides[get_agent_context] = _override_agent
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
async def _make_project(client: AsyncClient) -> str:
|
||||
resp = await client.post(
|
||||
"/api/projects",
|
||||
headers=_HDR,
|
||||
json={
|
||||
"name": f"Project {uuid4().hex[:6]}",
|
||||
"slug": f"proj-{uuid4().hex[:6]}",
|
||||
"git_url": "https://github.com/example/foo.git",
|
||||
"default_branch": "master",
|
||||
"assigned_cell": "backend",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.CREATED
|
||||
return str(resp.json()["id"])
|
||||
|
||||
|
||||
async def test_get_conventions_returns_map_and_health(client: AsyncClient) -> None:
|
||||
project_id = await _make_project(client)
|
||||
resp = await client.get(f"/api/projects/{project_id}/conventions", headers=_HDR)
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
body = resp.json()
|
||||
assert body["standard"]["rules"]["no_models_in_routers"]["level"] == "block"
|
||||
assert body["health"]["status"] in {"missing", "unknown", "ok", "degraded"}
|
||||
|
||||
|
||||
async def test_put_conventions_commits_back(client: AsyncClient) -> None:
|
||||
project_id = await _make_project(client)
|
||||
resp = await client.put(
|
||||
f"/api/projects/{project_id}/conventions",
|
||||
headers=_HDR,
|
||||
json={
|
||||
"version": 1,
|
||||
"languages": ["python"],
|
||||
"modules": [
|
||||
{"path": "app/models", "purpose": "models", "forbidden": ["route"]}
|
||||
],
|
||||
"rules": {"no_inline_comments": {"level": "warn"}},
|
||||
"custom": [],
|
||||
"waivers": [],
|
||||
},
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
# No workspace on the test project → PR not opened, but the call succeeds.
|
||||
assert resp.json()["created"] is False
|
||||
|
||||
|
||||
async def test_restore_conventions(client: AsyncClient) -> None:
|
||||
project_id = await _make_project(client)
|
||||
resp = await client.post(
|
||||
f"/api/projects/{project_id}/conventions/restore", headers=_HDR
|
||||
)
|
||||
assert resp.status_code == HTTPStatus.OK
|
||||
assert "branch" in resp.json()
|
||||
|
||||
|
||||
async def test_get_conventions_unknown_project_404(client: AsyncClient) -> None:
|
||||
resp = await client.get(f"/api/projects/{uuid4()}/conventions", headers=_HDR)
|
||||
assert resp.status_code == HTTPStatus.NOT_FOUND
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Project registration triggers a best-effort conventions scaffold (flag-gated)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest_asyncio
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import AgentTable, ProjectTable
|
||||
from roboco.models import AgentRole, AgentStatus, Team
|
||||
from roboco.models.project import ProjectCreate
|
||||
from roboco.services.project import ProjectService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class _SpyConventions:
|
||||
def __init__(self) -> None:
|
||||
self.scaffolded: list[ProjectTable] = []
|
||||
|
||||
async def scaffold(self, project: ProjectTable) -> None:
|
||||
self.scaffolded.append(project)
|
||||
|
||||
|
||||
class _BoomConventions:
|
||||
async def scaffold(self, _project: ProjectTable) -> None:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def setup(db_session: AsyncSession) -> AsyncIterator[dict]:
|
||||
agent = AgentTable(
|
||||
id=uuid4(),
|
||||
name="System",
|
||||
slug=f"system-{uuid4().hex[:8]}",
|
||||
role=AgentRole.SYSTEM,
|
||||
team=None,
|
||||
status=AgentStatus.ACTIVE,
|
||||
model_config={},
|
||||
system_prompt="system",
|
||||
capabilities=[],
|
||||
permissions={},
|
||||
metrics={},
|
||||
)
|
||||
db_session.add(agent)
|
||||
await db_session.flush()
|
||||
yield {"svc": ProjectService(db_session), "creator_id": agent.id}
|
||||
|
||||
|
||||
def _payload() -> ProjectCreate:
|
||||
return ProjectCreate(
|
||||
name="P",
|
||||
slug=f"p-{uuid4().hex[:8]}",
|
||||
git_url="https://github.com/example/r.git",
|
||||
assigned_cell=Team.BACKEND,
|
||||
)
|
||||
|
||||
|
||||
async def test_scaffold_invoked_when_flag_on(
|
||||
setup: dict, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "conventions_enabled", True)
|
||||
spy = _SpyConventions()
|
||||
monkeypatch.setattr(
|
||||
"roboco.services.conventions.get_conventions_service", lambda _s: spy
|
||||
)
|
||||
project = await setup["svc"].create(_payload(), setup["creator_id"])
|
||||
assert spy.scaffolded == [project]
|
||||
|
||||
|
||||
async def test_scaffold_not_invoked_when_flag_off(
|
||||
setup: dict, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "conventions_enabled", False)
|
||||
spy = _SpyConventions()
|
||||
monkeypatch.setattr(
|
||||
"roboco.services.conventions.get_conventions_service", lambda _s: spy
|
||||
)
|
||||
await setup["svc"].create(_payload(), setup["creator_id"])
|
||||
assert spy.scaffolded == []
|
||||
|
||||
|
||||
async def test_scaffold_failure_does_not_fail_registration(
|
||||
setup: dict, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "conventions_enabled", True)
|
||||
monkeypatch.setattr(
|
||||
"roboco.services.conventions.get_conventions_service",
|
||||
lambda _s: _BoomConventions(),
|
||||
)
|
||||
project = await setup["svc"].create(_payload(), setup["creator_id"])
|
||||
assert project.id is not None
|
||||
@@ -0,0 +1,110 @@
|
||||
"""TaskService.create appends the project's baseline constraints (flag-gated)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from roboco.config import settings
|
||||
from roboco.db.tables import AgentTable, ProjectTable
|
||||
from roboco.models import AgentRole, AgentStatus, Complexity, Team
|
||||
from roboco.models.base import TaskNature
|
||||
from roboco.models.task import TaskCreateRequest, TaskType
|
||||
from roboco.services.task import TaskService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
async def _seed(db: AsyncSession) -> tuple[AgentTable, 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,
|
||||
)
|
||||
db.add(project)
|
||||
await db.flush()
|
||||
return agent, project
|
||||
|
||||
|
||||
def _req(
|
||||
agent: AgentTable, project: ProjectTable, description: str
|
||||
) -> TaskCreateRequest:
|
||||
return TaskCreateRequest(
|
||||
title="A task",
|
||||
description=description,
|
||||
acceptance_criteria=["it works"],
|
||||
team=Team.BACKEND,
|
||||
created_by=UUID(str(agent.id)),
|
||||
task_type=TaskType.CODE,
|
||||
nature=TaskNature.TECHNICAL,
|
||||
estimated_complexity=Complexity.MEDIUM,
|
||||
project_id=UUID(str(project.id)),
|
||||
)
|
||||
|
||||
|
||||
async def test_baseline_attached_when_flag_on(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "conventions_enabled", True)
|
||||
agent, project = await _seed(db_session)
|
||||
task = await TaskService(db_session).create(_req(agent, project, "Do the work"))
|
||||
assert task.description is not None
|
||||
assert "## Constraints" in task.description
|
||||
assert "no models in routers" in task.description
|
||||
|
||||
|
||||
async def test_flag_off_attaches_nothing(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "conventions_enabled", False)
|
||||
agent, project = await _seed(db_session)
|
||||
task = await TaskService(db_session).create(_req(agent, project, "Do the work"))
|
||||
assert task.description == "Do the work"
|
||||
|
||||
|
||||
async def test_baseline_not_suppressed_by_agent_constraints_section(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# An agent-authored ## Constraints section must NOT suppress the mandatory
|
||||
# server baseline — both are present.
|
||||
monkeypatch.setattr(settings, "conventions_enabled", True)
|
||||
agent, project = await _seed(db_session)
|
||||
seeded = "Do the work\n\n## Constraints\n- a task-specific note"
|
||||
task = await TaskService(db_session).create(_req(agent, project, seeded))
|
||||
assert task.description is not None
|
||||
assert "a task-specific note" in task.description
|
||||
assert "no models in routers" in task.description
|
||||
|
||||
|
||||
async def test_baseline_attach_is_idempotent(
|
||||
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings, "conventions_enabled", True)
|
||||
agent, project = await _seed(db_session)
|
||||
svc = TaskService(db_session)
|
||||
task = await svc.create(_req(agent, project, "Do the work"))
|
||||
before = task.description
|
||||
await svc._attach_baseline_constraints(task)
|
||||
assert task.description == before
|
||||
assert task.description is not None
|
||||
assert task.description.count("no models in routers") == 1
|
||||
Reference in New Issue
Block a user