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 @@
"""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