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