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
@@ -0,0 +1,25 @@
"""compose_prompt appends the architectural-standard ambient layer when given."""
from __future__ import annotations
from roboco.agents.factories._base import compose_prompt
from roboco.models import AgentRole, Team
_AMBIENT = "## Architectural Standard\n- `app/routers`: HTTP routes"
def test_ambient_layer_included_when_provided() -> None:
prompt = compose_prompt(
AgentRole.DEVELOPER, Team.BACKEND, "be-dev-1", ambient=_AMBIENT
)
assert "## Architectural Standard" in prompt
def test_ambient_absent_when_none() -> None:
prompt = compose_prompt(AgentRole.DEVELOPER, Team.BACKEND, "be-dev-1")
assert "## Architectural Standard" not in prompt
def test_empty_ambient_not_injected() -> None:
prompt = compose_prompt(AgentRole.DEVELOPER, Team.BACKEND, "be-dev-1", ambient="")
assert "## Architectural Standard" not in prompt
@@ -0,0 +1,17 @@
"""The architectural-conventions subsystem is gated by a default-off flag."""
from __future__ import annotations
import os
from unittest import mock
from roboco.config import Settings
def test_conventions_disabled_by_default() -> None:
assert Settings().conventions_enabled is False
def test_conventions_reads_env_var() -> None:
with mock.patch.dict(os.environ, {"ROBOCO_CONVENTIONS_ENABLED": "true"}):
assert Settings().conventions_enabled is True
@@ -0,0 +1,81 @@
"""Python definition-kind classification (tree-sitter), precision-over-recall."""
from __future__ import annotations
from roboco.conventions.classify_python import classify_definitions
def test_pydantic_model_is_classified_model() -> None:
src = b"from pydantic import BaseModel\nclass UserCreate(BaseModel):\n x: int\n"
defs = classify_definitions(src)
assert ("UserCreate", 2, "model") in defs
def test_dotted_base_model_is_classified_model() -> None:
defs = classify_definitions(b"class M(pydantic.BaseModel):\n pass\n")
assert defs == [("M", 1, "model")]
def test_sqlalchemy_declarative_base_is_model() -> None:
defs = classify_definitions(b"class Account(Base):\n pass\n")
assert defs == [("Account", 1, "model")]
def test_router_decorated_function_is_route() -> None:
src = b"@router.get('/x')\ndef list_x():\n return 1\n"
defs = classify_definitions(src)
assert defs == [("list_x", 2, "route")]
def test_app_post_decorated_function_is_route() -> None:
src = b"@app.post('/y')\ndef create_y():\n return 1\n"
assert classify_definitions(src) == [("create_y", 2, "route")]
def test_blueprint_get_decorated_function_is_route() -> None:
# Any object with an HTTP-method attribute counts as a route handler.
src = b"@bp.delete('/z')\ndef drop_z():\n return 1\n"
assert classify_definitions(src) == [("drop_z", 2, "route")]
def test_plain_function_is_helper() -> None:
assert classify_definitions(b"def helper():\n pass\n") == [
("helper", 1, "helper")
]
def test_non_route_decorated_function_is_helper() -> None:
# A decorator that is not an HTTP route still leaves a plain function.
src = b"@functools.cache\ndef compute():\n return 1\n"
assert classify_definitions(src) == [("compute", 2, "helper")]
def test_ambiguous_class_abstains_to_other() -> None:
assert classify_definitions(b"class Thing:\n pass\n") == [("Thing", 1, "other")]
def test_class_with_unknown_base_abstains() -> None:
assert classify_definitions(b"class Widget(Gadget):\n pass\n") == [
("Widget", 1, "other")
]
def test_multiple_top_level_defs_in_order() -> None:
src = (
b"from pydantic import BaseModel\n"
b"class Req(BaseModel):\n x: int\n"
b"@router.put('/u')\ndef upd():\n return 1\n"
b"def util():\n pass\n"
)
defs = classify_definitions(src)
assert defs == [
("Req", 2, "model"),
("upd", 5, "route"),
("util", 7, "helper"),
]
def test_nested_defs_are_not_top_level() -> None:
# Only module-level definitions are classified (precision).
src = b"def outer():\n def inner():\n pass\n return inner\n"
assert classify_definitions(src) == [("outer", 1, "helper")]
@@ -0,0 +1,50 @@
"""TypeScript / TSX definition-kind classification, precision-over-recall."""
from __future__ import annotations
from roboco.conventions.classify_ts import classify_definitions
def test_zod_schema_const_is_model() -> None:
src = b"export const UserSchema = z.object({ id: z.string() });\n"
assert ("UserSchema", 1, "model") in classify_definitions(src, "typescript")
def test_chained_zod_schema_is_model() -> None:
src = b"export const P = z.object({}).partial();\n"
assert ("P", 1, "model") in classify_definitions(src, "typescript")
def test_entity_class_is_model() -> None:
src = b"@Entity()\nexport class User {}\n"
assert ("User", 2, "model") in classify_definitions(src, "typescript")
def test_controller_class_is_route() -> None:
src = b"@Controller('users')\nexport class UsersController {}\n"
assert ("UsersController", 2, "route") in classify_definitions(src, "typescript")
def test_arrow_component_is_component() -> None:
src = b"export const Btn = () => <div/>;\n"
assert ("Btn", 1, "component") in classify_definitions(src, "tsx")
def test_function_component_is_component() -> None:
src = b"export function Card() { return <span/>; }\n"
assert ("Card", 1, "component") in classify_definitions(src, "tsx")
def test_plain_function_abstains_to_other() -> None:
src = b"export function add(a: number, b: number) { return a + b; }\n"
assert classify_definitions(src, "typescript") == [("add", 1, "other")]
def test_plain_const_abstains_to_other() -> None:
src = b"export const TAX = 0.2;\n"
assert classify_definitions(src, "typescript") == [("TAX", 1, "other")]
def test_unparseable_source_abstains_quietly() -> None:
# tree-sitter yields ERROR nodes; we must not crash or invent findings.
assert classify_definitions(b"export const = = =;\n", "typescript") == []
+75
View File
@@ -0,0 +1,75 @@
"""CLI: JSONL findings on stdout, exit 0 when it ran, exit 3 when it could not."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from roboco.conventions.__main__ import main
from roboco.conventions.runner import ValidatorCouldNotRun
if TYPE_CHECKING:
from pathlib import Path
import pytest
_EXIT_COULD_NOT_RUN = 3
def _seed_repo(root: Path) -> None:
routers = root / "app" / "routers"
routers.mkdir(parents=True)
(routers / "u.py").write_text(
"from pydantic import BaseModel\nclass M(BaseModel):\n x: int\n"
)
conv = root / ".roboco"
conv.mkdir()
(conv / "conventions.yml").write_text(
"modules:\n - path: app/routers\n purpose: r\n forbidden: [model]\n"
)
def test_cli_prints_jsonl_and_exits_zero(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
_seed_repo(tmp_path)
rc = main(["check", "--root", str(tmp_path), "--files", "app/routers/u.py"])
assert rc == 0
lines = capsys.readouterr().out.strip().splitlines()
assert lines
assert json.loads(lines[0])["rule"] == "no_models_in_routers"
def test_cli_exits_zero_with_no_findings(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
(tmp_path / "clean.py").write_text("def helper():\n return 1\n")
rc = main(["check", "--root", str(tmp_path), "--files", "clean.py"])
assert rc == 0
assert capsys.readouterr().out.strip() == ""
def test_cli_exits_three_on_unparseable_config(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
conv = tmp_path / ".roboco"
conv.mkdir()
(conv / "conventions.yml").write_text("modules: [oops\n")
rc = main(["check", "--root", str(tmp_path), "--files"])
assert rc == _EXIT_COULD_NOT_RUN
assert "error" in capsys.readouterr().err
def test_cli_exits_three_when_validator_cannot_run(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
(tmp_path / "x.py").write_text("x = 1\n")
def boom(*_args: object, **_kw: object) -> list:
raise ValidatorCouldNotRun("no grammar")
monkeypatch.setattr("roboco.conventions.__main__.run", boom)
rc = main(["check", "--root", str(tmp_path), "--files", "x.py"])
assert rc == _EXIT_COULD_NOT_RUN
payload = json.loads(capsys.readouterr().err)
assert "error" in payload
+49
View File
@@ -0,0 +1,49 @@
"""Smoke the real ``python -m roboco.conventions`` entrypoint as a subprocess.
Guards the contract the agent image depends on: the module runs, loads its
tree-sitter grammars, and emits JSONL findings with exit 0.
"""
from __future__ import annotations
import json
import subprocess
import sys
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
def test_cli_module_entrypoint_emits_jsonl(tmp_path: Path) -> None:
routers = tmp_path / "app" / "routers"
routers.mkdir(parents=True)
(routers / "u.py").write_text(
"from pydantic import BaseModel\nclass M(BaseModel):\n x: int\n"
)
conv = tmp_path / ".roboco"
conv.mkdir()
(conv / "conventions.yml").write_text(
"modules:\n - path: app/routers\n purpose: r\n forbidden: [model]\n"
)
result = subprocess.run(
[
sys.executable,
"-m",
"roboco.conventions",
"check",
"--root",
str(tmp_path),
"--files",
"app/routers/u.py",
],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
lines = [line for line in result.stdout.strip().splitlines() if line]
assert lines
assert json.loads(lines[0])["rule"] == "no_models_in_routers"
+49
View File
@@ -0,0 +1,49 @@
"""Custom regex rules, scoped by language."""
from __future__ import annotations
from roboco.conventions.custom import check_custom
from roboco.foundation.policy.conventions.models import ConventionsStandard, CustomRule
_NO_PRINT = CustomRule(
id="no-print",
pattern=r"\bprint\(",
message="use the logger, not print()",
level="warn",
languages=["python"],
)
def test_custom_rule_matches_in_scoped_language() -> None:
std = ConventionsStandard(custom=[_NO_PRINT])
findings = check_custom("a.py", b"print('x')\n", "python", std)
assert len(findings) == 1
assert findings[0].rule == "no-print"
assert findings[0].level == "warn"
assert findings[0].message == "use the logger, not print()"
def test_custom_rule_skips_other_language() -> None:
std = ConventionsStandard(custom=[_NO_PRINT])
assert check_custom("a.ts", b"print('x')\n", "typescript", std) == []
def test_unscoped_custom_rule_applies_to_all_languages() -> None:
rule = CustomRule(
id="no-log", pattern=r"console\.log", message="no console.log", level="warn"
)
std = ConventionsStandard(custom=[rule])
assert check_custom("a.ts", b"console.log(1)\n", "typescript", std)
def test_custom_rule_reports_correct_line() -> None:
print_line = 3
std = ConventionsStandard(custom=[_NO_PRINT])
findings = check_custom("a.py", b"x = 1\ny = 2\nprint(x)\n", "python", std)
assert findings[0].line == print_line
def test_bad_regex_abstains_without_crashing() -> None:
rule = CustomRule(id="bad", pattern=r"(unclosed", message="m", level="block")
std = ConventionsStandard(custom=[rule])
assert check_custom("a.py", b"anything\n", "python", std) == []
+67
View File
@@ -0,0 +1,67 @@
"""Hygiene checks: inline comments + lint/type suppressions."""
from __future__ import annotations
from roboco.conventions.hygiene import check_hygiene
from roboco.foundation.policy.conventions.models import ConventionsStandard, Rule
_STD = ConventionsStandard()
def _rules(findings: list, rule: str) -> list:
return [f for f in findings if f.rule == rule]
def test_trailing_comment_is_flagged_inline() -> None:
findings = check_hygiene("a.py", b"x = 1 # set x\n", "python", _STD)
inline = _rules(findings, "no_inline_comments")
assert inline and inline[0].level == "warn"
assert inline[0].line == 1
def test_full_line_comment_is_not_inline() -> None:
findings = check_hygiene("a.py", b"# a heading\nx = 1\n", "python", _STD)
assert _rules(findings, "no_inline_comments") == []
def test_indented_full_line_comment_is_not_inline() -> None:
src = b"def f():\n # explain\n return 1\n"
findings = check_hygiene("a.py", src, "python", _STD)
assert _rules(findings, "no_inline_comments") == []
def test_python_type_ignore_flags_suppression_block() -> None:
findings = check_hygiene("a.py", b"y = bad() # type: ignore\n", "python", _STD)
sup = _rules(findings, "no_lint_suppressions")
assert sup and sup[0].level == "block"
def test_python_noqa_flags_suppression() -> None:
findings = check_hygiene("a.py", b"import os # noqa: F401\n", "python", _STD)
assert _rules(findings, "no_lint_suppressions")
def test_ts_eslint_disable_flags_suppression() -> None:
src = b"// eslint-disable-next-line\nconst x = 1;\n"
findings = check_hygiene("a.ts", src, "typescript", _STD)
assert _rules(findings, "no_lint_suppressions")
def test_ts_ignore_flags_suppression() -> None:
src = b"// @ts-ignore\nconst x: number = 'no';\n"
findings = check_hygiene("a.ts", src, "typescript", _STD)
assert _rules(findings, "no_lint_suppressions")
def test_python_marker_not_applied_to_typescript() -> None:
src = b"// noqa is a python thing\nconst x = 1;\n"
findings = check_hygiene("a.ts", src, "typescript", _STD)
assert _rules(findings, "no_lint_suppressions") == []
def test_rule_level_override_from_standard() -> None:
std = ConventionsStandard(
rules={"no_inline_comments": Rule(name="no_inline_comments", level="block")}
)
findings = check_hygiene("a.py", b"x = 1 # c\n", "python", std)
assert _rules(findings, "no_inline_comments")[0].level == "block"
+93
View File
@@ -0,0 +1,93 @@
"""Placement checks: a def whose kind is forbidden in its module is flagged."""
from __future__ import annotations
import json
from roboco.conventions.placement import Definition, check_placement
from roboco.foundation.policy.conventions.models import (
ConventionsStandard,
Module,
Rule,
)
_MODEL_LINE = 2
_DEFS: list[Definition] = [("UserCreate", _MODEL_LINE, "model")]
def test_forbidden_kind_in_module_is_flagged() -> None:
std = ConventionsStandard(
modules=[Module(path="app/routers", purpose="routes", forbidden=["model"])]
)
findings = check_placement("app/routers/users.py", _DEFS, std)
assert len(findings) == 1
f = findings[0]
assert f.kind == "model"
assert f.rule == "no_models_in_routers"
assert f.level == "block"
assert f.line == _MODEL_LINE
assert "app/routers" in f.message
def test_allowed_kind_in_module_is_not_flagged() -> None:
std = ConventionsStandard(
modules=[Module(path="app/models", purpose="models", forbidden=["route"])]
)
assert check_placement("app/models/user.py", _DEFS, std) == []
def test_no_matching_module_yields_no_finding() -> None:
std = ConventionsStandard(
modules=[Module(path="app/routers", purpose="routes", forbidden=["model"])]
)
assert check_placement("lib/helpers.py", _DEFS, std) == []
def test_rule_level_from_standard_is_respected() -> None:
std = ConventionsStandard(
modules=[Module(path="app/routers", purpose="routes", forbidden=["model"])],
rules={"no_models_in_routers": Rule(name="no_models_in_routers", level="warn")},
)
findings = check_placement("app/routers/users.py", _DEFS, std)
assert findings[0].level == "warn"
def test_longest_matching_module_wins() -> None:
std = ConventionsStandard(
modules=[
Module(path="app", purpose="root", forbidden=[]),
Module(path="app/routers", purpose="routes", forbidden=["model"]),
]
)
findings = check_placement("app/routers/users.py", _DEFS, std)
assert len(findings) == 1
assert findings[0].kind == "model"
def test_prefix_must_be_on_a_path_boundary() -> None:
# "app/routers" must not match "app/routers_legacy/..." spuriously.
std = ConventionsStandard(
modules=[Module(path="app/routers", purpose="routes", forbidden=["model"])]
)
assert check_placement("app/routers_legacy/users.py", _DEFS, std) == []
def test_finding_serializes_to_json_line() -> None:
std = ConventionsStandard(
modules=[Module(path="app/routers", purpose="routes", forbidden=["model"])]
)
f = check_placement("app/routers/users.py", _DEFS, std)[0]
payload = json.loads(f.as_json())
assert payload["rule"] == "no_models_in_routers"
assert payload["file"] == "app/routers/users.py"
assert payload["line"] == _MODEL_LINE
assert payload["level"] == "block"
assert set(payload) == {
"file",
"line",
"kind",
"rule",
"level",
"message",
"fix_hint",
}
+78
View File
@@ -0,0 +1,78 @@
"""Runner: per-file dispatch, waiver filtering, fail-loud on grammar failure."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from roboco.conventions.grammars import GrammarUnavailable
from roboco.conventions.runner import ValidatorCouldNotRun, run
from roboco.foundation.policy.conventions.models import (
ConventionsStandard,
Module,
Waiver,
)
if TYPE_CHECKING:
from pathlib import Path
_MODEL_PY = b"from pydantic import BaseModel\nclass M(BaseModel):\n x: int\n"
def _write(root: Path, rel: str, content: bytes) -> None:
path = root / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
def test_runner_flags_python_model_in_router(tmp_path: Path) -> None:
_write(tmp_path, "app/routers/users.py", _MODEL_PY)
std = ConventionsStandard(
modules=[Module(path="app/routers", purpose="r", forbidden=["model"])]
)
findings = run(tmp_path, ["app/routers/users.py"], std)
assert [f.rule for f in findings] == ["no_models_in_routers"]
def test_runner_drops_waived_finding(tmp_path: Path) -> None:
_write(tmp_path, "app/routers/legacy.py", _MODEL_PY)
std = ConventionsStandard(
modules=[Module(path="app/routers", purpose="r", forbidden=["model"])],
waivers=[
Waiver(
path="app/routers/legacy.py", rule="no_models_in_routers", reason="x"
)
],
)
assert run(tmp_path, ["app/routers/legacy.py"], std) == []
def test_runner_flags_ts_component_in_wrong_module(tmp_path: Path) -> None:
_write(tmp_path, "src/pages/Home.tsx", b"export const Home = () => <div/>;\n")
std = ConventionsStandard(
modules=[Module(path="src/pages", purpose="pages", forbidden=["component"])]
)
findings = run(tmp_path, ["src/pages/Home.tsx"], std)
assert any(f.kind == "component" for f in findings)
def test_runner_skips_unsupported_extension(tmp_path: Path) -> None:
_write(tmp_path, "README.md", b"# hi\n")
assert run(tmp_path, ["README.md"], ConventionsStandard()) == []
def test_runner_skips_missing_file(tmp_path: Path) -> None:
assert run(tmp_path, ["gone.py"], ConventionsStandard()) == []
def test_runner_is_fail_loud_on_grammar_failure(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_write(tmp_path, "x.py", b"x = 1\n")
def boom(_source: bytes) -> list:
raise GrammarUnavailable("python")
monkeypatch.setattr("roboco.conventions.classify_python.classify_definitions", boom)
with pytest.raises(ValidatorCouldNotRun):
run(tmp_path, ["x.py"], ConventionsStandard())
+68
View File
@@ -0,0 +1,68 @@
"""Repo auto-scan + scaffold-draft renderer."""
from __future__ import annotations
from typing import TYPE_CHECKING
from roboco.conventions.scan import derive_from_scan, render_yaml
from roboco.foundation.policy.conventions.models import ConventionsStandard
if TYPE_CHECKING:
from pathlib import Path
def _sample_repo(root: Path) -> None:
(root / "app" / "routers").mkdir(parents=True)
(root / "app" / "models").mkdir(parents=True)
(root / "app" / "services").mkdir(parents=True)
(root / "app" / "routers" / "users.py").write_text("x = 1\n")
(root / "app" / "models" / "user.py").write_text("y = 2\n")
(root / "app" / "services" / "logic.py").write_text("z = 3\n")
def test_scan_derives_router_module_forbidding_models(tmp_path: Path) -> None:
_sample_repo(tmp_path)
std = derive_from_scan(tmp_path)
routers = [m for m in std.modules if m.path == "app/routers"]
assert routers and "model" in routers[0].forbidden
def test_scan_detects_python_language(tmp_path: Path) -> None:
_sample_repo(tmp_path)
assert "python" in derive_from_scan(tmp_path).languages
def test_scan_seeds_builtin_rules(tmp_path: Path) -> None:
_sample_repo(tmp_path)
std = derive_from_scan(tmp_path)
assert std.rules["no_models_in_routers"].level == "block"
assert std.rules["no_inline_comments"].level == "warn"
def test_scan_ignores_vendored_directories(tmp_path: Path) -> None:
(tmp_path / "node_modules" / "pkg" / "routers").mkdir(parents=True)
(tmp_path / ".venv" / "lib" / "models").mkdir(parents=True)
std = derive_from_scan(tmp_path)
assert std.modules == []
def test_scan_lifts_claude_md_imperative_into_custom_rule(tmp_path: Path) -> None:
_sample_repo(tmp_path)
(tmp_path / "CLAUDE.md").write_text("- Never use `print()`; use the logger.\n")
custom = derive_from_scan(tmp_path).custom
assert custom
assert custom[0].level == "warn"
assert "print" in custom[0].pattern
def test_render_yaml_round_trips_through_parse(tmp_path: Path) -> None:
_sample_repo(tmp_path)
(tmp_path / "CLAUDE.md").write_text("Do not call `eval()` anywhere.\n")
std = derive_from_scan(tmp_path)
reparsed = ConventionsStandard.parse_yaml(render_yaml(std))
assert reparsed == std
def test_render_yaml_round_trips_empty_standard() -> None:
std = ConventionsStandard()
assert ConventionsStandard.parse_yaml(render_yaml(std)) == std
@@ -0,0 +1,28 @@
"""TaskDescription.constraints: renders as a section when present, else absent."""
from __future__ import annotations
from typing import Any
from roboco.foundation.identity import Team
from roboco.foundation.policy.content.models import TaskDescription, WorkUnit
def _desc(**overrides: Any) -> TaskDescription:
fields: dict[str, Any] = {
"objective": "Build the thing properly",
"the_work": [WorkUnit(team=Team.BACKEND, summary="do the work", items=["a"])],
"acceptance_criteria": ["it works"],
}
fields.update(overrides)
return TaskDescription(**fields)
def test_constraints_render_as_section() -> None:
md = _desc(constraints=["no models in routers"]).render_markdown()
assert "## Constraints" in md
assert "no models in routers" in md
def test_no_constraints_section_when_empty() -> None:
assert "## Constraints" not in _desc().render_markdown()
@@ -0,0 +1,28 @@
"""The panel TS ConventionsStandard type mirrors the Python model fields.
A drift here means the panel editor and the backend disagree on the shape of
``.roboco/conventions.yml`` caught at test time, not in production.
"""
from __future__ import annotations
import re
from pathlib import Path
from roboco.foundation.policy.conventions.models import ConventionsStandard
_REPO_ROOT = Path(__file__).resolve().parents[5]
_TS_FILE = _REPO_ROOT / "panel" / "src" / "lib" / "api" / "conventions.ts"
def _ts_interface_fields(text: str, name: str) -> set[str]:
match = re.search(rf"export interface {name} \{{(.+?)\n\}}", text, re.DOTALL)
assert match, f"interface {name} not found in conventions.ts"
return set(re.findall(r"^\s*(\w+)\s*[?:]", match.group(1), re.MULTILINE))
def test_ts_standard_matches_python_fields() -> None:
text = _TS_FILE.read_text()
ts_keys = _ts_interface_fields(text, "ConventionsStandard")
py_keys = set(ConventionsStandard.model_fields.keys())
assert ts_keys == py_keys
@@ -0,0 +1,111 @@
"""The i_am_done conventions gate: block-level violations refuse the submit.
With the flag on, a ``block`` finding (or a validator that could not run) on the
dev's changed files refuses i_am_done with the offending ``file:line`` + a fix
hint. ``warn`` findings never block; the flag-off path is fully inert.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from roboco.config import settings
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
_BLOCK_RESULT: dict[str, Any] = {
"findings": [
{
"file": "app/routers/u.py",
"line": 2,
"level": "block",
"fix_hint": "move it into models/",
}
],
"could_not_run": False,
}
def _make_choreographer(*, check_result: dict[str, Any]) -> Choreographer:
base: dict[str, Any] = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
base["git"].conventions_check_for_task.return_value = check_result
return Choreographer(ChoreographerDeps(**base))
def _ctx() -> MagicMock:
ctx = MagicMock()
ctx.briefing = {}
return ctx
@pytest.mark.asyncio
async def test_block_finding_refuses_with_location(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
c = _make_choreographer(check_result=_BLOCK_RESULT)
env = await c._conventions_gate(_ctx())
assert env is not None
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "app/routers/u.py:2" in body["remediate"]
assert "move it into models/" in body["remediate"]
@pytest.mark.asyncio
async def test_warn_only_does_not_block(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
c = _make_choreographer(
check_result={
"findings": [{"file": "x.py", "line": 1, "level": "warn", "fix_hint": "h"}],
"could_not_run": False,
}
)
assert await c._conventions_gate(_ctx()) is None
@pytest.mark.asyncio
async def test_could_not_run_blocks_loud(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
c = _make_choreographer(check_result={"findings": [], "could_not_run": True})
env = await c._conventions_gate(_ctx())
assert env is not None
assert "could not run" in env.as_dict()["message"]
@pytest.mark.asyncio
async def test_flag_off_is_inert(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "conventions_enabled", False)
c = _make_choreographer(check_result=_BLOCK_RESULT)
assert await c._conventions_gate(_ctx()) is None
def test_no_findings_passes() -> None:
result: dict[str, Any] = {"findings": [], "could_not_run": False}
assert Choreographer._conventions_rejection(result, {}) is None
@pytest.mark.asyncio
async def test_gate_records_findings_even_when_blocking(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
recorded: list[dict[str, Any]] = []
async def _spy(_task: Any, result: dict[str, Any]) -> None:
recorded.append(result)
c = _make_choreographer(check_result=_BLOCK_RESULT)
monkeypatch.setattr(c, "_record_convention_findings", _spy)
env = await c._conventions_gate(_ctx())
assert env is not None # still blocks
assert recorded and recorded[0] is _BLOCK_RESULT
@@ -0,0 +1,87 @@
"""The pr_pass conventions gate: a reviewer can't PASS a PR with block violations.
``_gate_decision`` runs ``_conventions_guard`` for ``verb == "pr_pass"`` only
(pr_fail stays available), exactly like the toolchain guard. These exercise the
shared guard the pr_pass path invokes: a ``block`` finding (or a validator that
could not run) refuses; ``warn`` passes; flag-off is inert.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
_BLOCK_RESULT: dict[str, Any] = {
"findings": [
{
"file": "app/routers/u.py",
"line": 2,
"level": "block",
"fix_hint": "move it into models/",
}
],
"could_not_run": False,
}
def _make_choreographer(*, check_result: dict[str, Any]) -> Choreographer:
base: dict[str, Any] = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
base["git"].conventions_check_for_task.return_value = check_result
return Choreographer(ChoreographerDeps(**base))
@pytest.mark.asyncio
async def test_pr_pass_guard_blocks_on_block_finding(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
c = _make_choreographer(check_result=_BLOCK_RESULT)
env = await c._conventions_guard(uuid4(), MagicMock(), {})
assert env is not None
body = env.as_dict()
assert body["error"] == "invalid_state"
assert "app/routers/u.py:2" in body["remediate"]
assert "waiver" in body["remediate"]
@pytest.mark.asyncio
async def test_pr_pass_guard_allows_warn(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
c = _make_choreographer(
check_result={
"findings": [{"file": "x.py", "line": 1, "level": "warn", "fix_hint": "h"}],
"could_not_run": False,
}
)
assert await c._conventions_guard(uuid4(), MagicMock(), {}) is None
@pytest.mark.asyncio
async def test_pr_pass_guard_blocks_when_validator_cannot_run(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
c = _make_choreographer(check_result={"findings": [], "could_not_run": True})
assert await c._conventions_guard(uuid4(), MagicMock(), {}) is not None
@pytest.mark.asyncio
async def test_pr_pass_guard_inert_when_flag_off(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", False)
c = _make_choreographer(check_result=_BLOCK_RESULT)
assert await c._conventions_guard(uuid4(), MagicMock(), {}) is None
@@ -0,0 +1,83 @@
"""QA claim_review evidence carries the conventions validator findings (gated)."""
from __future__ import annotations
from typing import Any
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.services.gateway.choreographer import Choreographer, ChoreographerDeps
from roboco.services.gateway.evidence_builder import build_evidence_for_task
def _make_choreographer(*, check_result: dict[str, Any]) -> Choreographer:
base: dict[str, Any] = {
"task": AsyncMock(),
"work_session": AsyncMock(),
"git": AsyncMock(),
"a2a": AsyncMock(),
"journal": AsyncMock(),
"audit": AsyncMock(),
"evidence_repo": AsyncMock(),
}
base["git"].conventions_check_for_task.return_value = check_result
return Choreographer(ChoreographerDeps(**base))
@pytest.mark.asyncio
async def test_findings_surfaced_when_flag_on(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
findings = [{"file": "x.py", "line": 1, "level": "warn", "fix_hint": "h"}]
c = _make_choreographer(check_result={"findings": findings, "could_not_run": False})
assert await c._qa_convention_findings(uuid4(), MagicMock()) == findings
@pytest.mark.asyncio
async def test_empty_when_flag_off(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "conventions_enabled", False)
c = _make_choreographer(
check_result={"findings": [{"file": "x"}], "could_not_run": False}
)
assert await c._qa_convention_findings(uuid4(), MagicMock()) == []
@pytest.mark.asyncio
async def test_could_not_run_surfaced_as_single_entry(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
c = _make_choreographer(
check_result={"findings": [], "could_not_run": True, "reason": "boom"}
)
out = await c._qa_convention_findings(uuid4(), MagicMock())
assert len(out) == 1
assert out[0]["could_not_run"] is True
assert out[0]["reason"] == "boom"
def _stub_task() -> MagicMock:
task = MagicMock()
task.pr_number = None
task.pr_url = None
task.commits = []
task.dev_notes = None
task.acceptance_criteria_status = []
return task
def test_evidence_payload_includes_convention_findings() -> None:
findings = [{"file": "x", "line": 1}]
ev = build_evidence_for_task(
_stub_task(),
journal_highlights=[],
files_changed=[],
convention_findings=findings,
)
assert ev.as_dict()["convention_findings"] == findings
def test_evidence_payload_convention_findings_default_empty() -> None:
ev = build_evidence_for_task(_stub_task(), journal_highlights=[], files_changed=[])
assert ev.as_dict()["convention_findings"] == []
+1 -1
View File
@@ -230,7 +230,7 @@ def _wire_spawn_mocks(
monkeypatch.setattr(
orch,
"_generate_composed_prompt",
lambda _aid: Path("/tmp/intake-1-prompt.md"),
lambda *_args, **_kwargs: Path("/tmp/intake-1-prompt.md"),
)
monkeypatch.setattr(
orch,
@@ -0,0 +1,75 @@
"""The first-clone conventions scaffold hook: flag-gated, file-absent, once."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock
import roboco.services.workspace as ws_mod
from roboco.config import settings
from roboco.services.workspace import WorkspaceService
if TYPE_CHECKING:
from pathlib import Path
import pytest
class _SpyConventions:
def __init__(self) -> None:
self.scaffolded: list[Any] = []
async def scaffold(self, project: Any, *, workspace: Path) -> None:
self.scaffolded.append((project, workspace))
def _install_spy(monkeypatch: pytest.MonkeyPatch) -> _SpyConventions:
ws_mod._SCAFFOLD_ATTEMPTED.clear()
spy = _SpyConventions()
monkeypatch.setattr(
"roboco.services.conventions.get_conventions_service", lambda _s: spy
)
return spy
async def test_scaffold_fires_when_flag_on_and_file_absent(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
spy = _install_spy(monkeypatch)
svc = WorkspaceService(AsyncMock())
await svc._maybe_scaffold_conventions(object(), "proj-a", tmp_path)
assert len(spy.scaffolded) == 1
async def test_no_scaffold_when_flag_off(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", False)
spy = _install_spy(monkeypatch)
svc = WorkspaceService(AsyncMock())
await svc._maybe_scaffold_conventions(object(), "proj-b", tmp_path)
assert spy.scaffolded == []
async def test_no_scaffold_when_file_already_present(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
spy = _install_spy(monkeypatch)
(tmp_path / ".roboco").mkdir()
(tmp_path / ".roboco" / "conventions.yml").write_text("version: 1\n")
svc = WorkspaceService(AsyncMock())
await svc._maybe_scaffold_conventions(object(), "proj-c", tmp_path)
assert spy.scaffolded == []
async def test_scaffold_attempted_once_per_project(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(settings, "conventions_enabled", True)
spy = _install_spy(monkeypatch)
svc = WorkspaceService(AsyncMock())
await svc._maybe_scaffold_conventions(object(), "proj-d", tmp_path)
await svc._maybe_scaffold_conventions(object(), "proj-d", tmp_path)
assert len(spy.scaffolded) == 1