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:
Renzo F
2026-06-22 12:37:46 +02:00
committed by GitHub
co-authored by Renn F
parent 01e10ad693
commit 16789c1ca7
76 changed files with 4910 additions and 74 deletions
@@ -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