From e690afd1976490b9583da117bb0db9c047831492 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 03:25:57 +0200 Subject: [PATCH 01/29] feat(conventions): standard schema models + effective-map merge --- pyproject.toml | 2 + .../foundation/policy/conventions/__init__.py | 33 +++++ .../policy/conventions/effective_map.py | 65 +++++++++ .../foundation/policy/conventions/models.py | 129 ++++++++++++++++++ .../conventions/test_conventions_models.py | 99 ++++++++++++++ .../policy/conventions/test_effective_map.py | 89 ++++++++++++ uv.lock | 13 ++ 7 files changed, 430 insertions(+) create mode 100644 roboco/foundation/policy/conventions/__init__.py create mode 100644 roboco/foundation/policy/conventions/effective_map.py create mode 100644 roboco/foundation/policy/conventions/models.py create mode 100644 tests/unit/foundation/policy/conventions/test_conventions_models.py create mode 100644 tests/unit/foundation/policy/conventions/test_effective_map.py diff --git a/pyproject.toml b/pyproject.toml index 75ea01c9..51e852d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ # Direct imports (promoted from transitive) "cryptography", # utils/crypto.py — Fernet-encrypted project git tokens "packaging", # services/toolchain.py — PEP 440 requires-python resolution + "pyyaml", # foundation/policy/conventions — .roboco/conventions.yml parse "claude-agent-sdk>=0.2.105", ] @@ -72,6 +73,7 @@ dev = [ # Type Stubs "types-passlib", "types-python-jose", + "types-PyYAML", # Development "ipython", diff --git a/roboco/foundation/policy/conventions/__init__.py b/roboco/foundation/policy/conventions/__init__.py new file mode 100644 index 00000000..2bda03c3 --- /dev/null +++ b/roboco/foundation/policy/conventions/__init__.py @@ -0,0 +1,33 @@ +"""Architectural-conventions standard: schema models + effective-map merge. + +Pure foundation layer (no IO/DB). The validator CLI (``roboco.conventions``), +``ConventionsService``, and the gateway gates all build on these types. +""" + +from __future__ import annotations + +from .effective_map import effective_map +from .models import ( + BUILTIN_RULES, + ConventionsParseError, + ConventionsStandard, + CustomRule, + DefinitionKind, + Module, + Rule, + RuleLevel, + Waiver, +) + +__all__ = [ + "BUILTIN_RULES", + "ConventionsParseError", + "ConventionsStandard", + "CustomRule", + "DefinitionKind", + "Module", + "Rule", + "RuleLevel", + "Waiver", + "effective_map", +] diff --git a/roboco/foundation/policy/conventions/effective_map.py b/roboco/foundation/policy/conventions/effective_map.py new file mode 100644 index 00000000..a2cef4a1 --- /dev/null +++ b/roboco/foundation/policy/conventions/effective_map.py @@ -0,0 +1,65 @@ +"""Effective-map merge: auto-derived defaults overlaid by the committed file. + +Every consumer (validator, ambient injection, baseline constraints) reads the +*effective* map, so behaviour is identical whether the file is present, +absent, or partial. Precedence, per field: + +- ``rules``: ``BUILTIN_RULES`` < derived < file (per key). +- ``modules``: derived, with file modules overriding by ``path`` (and new + paths appended in file order). +- ``custom`` / ``waivers`` / ``version``: the file's when a file is present, + else the derived value (the file is the curated replacement). +- ``languages``: union (derived order, then file-only extras). + +Pure: no IO, no DB. +""" + +from __future__ import annotations + +from .models import BUILTIN_RULES, ConventionsStandard, Module, Rule + + +def _merge_rules( + derived: ConventionsStandard, file: ConventionsStandard | None +) -> dict[str, Rule]: + merged: dict[str, Rule] = { + name: Rule(name=name, level=level) for name, level in BUILTIN_RULES.items() + } + merged.update(derived.rules) + if file is not None: + merged.update(file.rules) + return merged + + +def _merge_modules( + derived: ConventionsStandard, file: ConventionsStandard | None +) -> list[Module]: + modules = {m.path: m for m in derived.modules} + for m in file.modules if file is not None else []: + modules[m.path] = m + return list(modules.values()) + + +def _union_languages( + derived: ConventionsStandard, file: ConventionsStandard | None +) -> list[str]: + languages = list(derived.languages) + for lang in file.languages if file is not None else []: + if lang not in languages: + languages.append(lang) + return languages + + +def effective_map( + derived: ConventionsStandard, file: ConventionsStandard | None +) -> ConventionsStandard: + """Merge auto-derived defaults with the committed file into one standard.""" + curated = file if file is not None else derived + return ConventionsStandard( + version=curated.version, + languages=_union_languages(derived, file), + modules=_merge_modules(derived, file), + rules=_merge_rules(derived, file), + custom=curated.custom, + waivers=curated.waivers, + ) diff --git a/roboco/foundation/policy/conventions/models.py b/roboco/foundation/policy/conventions/models.py new file mode 100644 index 00000000..5d295bfa --- /dev/null +++ b/roboco/foundation/policy/conventions/models.py @@ -0,0 +1,129 @@ +"""Architectural-conventions standard — schema models + YAML parse. + +The standard is the repo-canonical ``.roboco/conventions.yml``: a per-project +architecture map (which definition *kinds* belong in which modules), a +toggleable rule set, custom regex rules, and waivers. These models are pure +(no IO, no DB) — the validator, the service, and the effective-map merge all +build on them. ``parse_yaml`` is the single entry point from raw file text to +a validated ``ConventionsStandard`` (or a ``ConventionsParseError``). +""" + +from __future__ import annotations + +from typing import Any, Literal + +import yaml +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator + +RuleLevel = Literal["warn", "block"] +DefinitionKind = Literal[ + "model", "route", "helper", "business_logic", "component", "other" +] + + +class ConventionsParseError(ValueError): + """Raised when ``.roboco/conventions.yml`` is malformed or invalid.""" + + def __init__(self, reason: str) -> None: + super().__init__(reason) + self.reason = reason + + +# The org-default rule set: applied to every project's effective map before the +# committed file or auto-derived rules overlay it. Keep in sync with the +# validator's rule emitters and the panel's rule list. +BUILTIN_RULES: dict[str, RuleLevel] = { + "no_models_in_routers": "block", + "no_helpers_in_routers": "block", + "no_lint_suppressions": "block", + "no_inline_comments": "warn", +} + + +class _Base(BaseModel): + """Shared config: ignore unknown keys for forward-compatibility.""" + + model_config = ConfigDict(extra="ignore") + + +class Module(_Base): + """One module boundary: a path prefix, its purpose, forbidden def kinds.""" + + path: str + purpose: str + forbidden: list[DefinitionKind] = Field(default_factory=list) + + +class Rule(_Base): + """A toggleable rule — its name and the level it fires at.""" + + name: str + level: RuleLevel + + +class CustomRule(_Base): + """A project-specific regex rule, optionally scoped to languages.""" + + id: str + pattern: str + message: str + level: RuleLevel + languages: list[str] = Field(default_factory=list) + + +class Waiver(_Base): + """An accountable escape hatch: a (path, rule) the gate must not flag.""" + + path: str + rule: str + reason: str + + +class ConventionsStandard(_Base): + """The parsed standard (raw file *or* the merged effective map).""" + + version: int = 1 + languages: list[str] = Field(default_factory=list) + modules: list[Module] = Field(default_factory=list) + rules: dict[str, Rule] = Field(default_factory=dict) + custom: list[CustomRule] = Field(default_factory=list) + waivers: list[Waiver] = Field(default_factory=list) + + @field_validator("rules", mode="before") + @classmethod + def _name_rules_from_keys(cls, v: Any) -> Any: + """Inject the mapping key as each rule's ``name``. + + The YAML keys rules by name with a ``{level: ...}`` value; the model + carries the name on the rule itself. Accept either shape so a ``Rule`` + constructed directly also passes through unchanged. + """ + if not isinstance(v, dict): + return v + out: dict[str, Any] = {} + for name, spec in v.items(): + if isinstance(spec, dict): + out[name] = {"name": name, **spec} + else: + out[name] = spec + return out + + @classmethod + def parse_yaml(cls, text: str) -> ConventionsStandard: + """Parse ``.roboco/conventions.yml`` text into a validated standard. + + Raises ``ConventionsParseError`` on malformed YAML, a non-mapping + top level, or any schema violation (e.g. an unknown rule level). + """ + try: + data = yaml.safe_load(text) + except yaml.YAMLError as exc: + raise ConventionsParseError(f"malformed YAML: {exc}") from exc + if data is None: + return cls() + if not isinstance(data, dict): + raise ConventionsParseError("top-level conventions must be a mapping") + try: + return cls.model_validate(data) + except ValidationError as exc: + raise ConventionsParseError(str(exc)) from exc diff --git a/tests/unit/foundation/policy/conventions/test_conventions_models.py b/tests/unit/foundation/policy/conventions/test_conventions_models.py new file mode 100644 index 00000000..4bfef2b0 --- /dev/null +++ b/tests/unit/foundation/policy/conventions/test_conventions_models.py @@ -0,0 +1,99 @@ +"""Schema-model + YAML-parse tests for the architectural-conventions standard.""" + +from __future__ import annotations + +import pytest +from roboco.foundation.policy.conventions.models import ( + BUILTIN_RULES, + ConventionsParseError, + ConventionsStandard, + CustomRule, + Module, + Rule, + Waiver, +) + +_VALID_YAML = """ +version: 1 +languages: [python, typescript] +modules: + - path: app/routers + purpose: HTTP routes + forbidden: [model, helper] + - path: app/models + purpose: Pydantic / ORM models +rules: + no_models_in_routers: { level: block } + no_inline_comments: { level: warn } +custom: + - id: no-print + pattern: '\\bprint\\(' + message: use the logger + level: warn + languages: [python] +waivers: + - path: app/routers/legacy.py + rule: no_models_in_routers + reason: extraction tracked separately +""" + + +def test_valid_yaml_parses_to_standard() -> None: + std = ConventionsStandard.parse_yaml(_VALID_YAML) + assert std.version == 1 + assert std.languages == ["python", "typescript"] + assert std.modules[0].path == "app/routers" + assert std.modules[0].forbidden == ["model", "helper"] + assert std.rules["no_models_in_routers"].level == "block" + assert std.rules["no_models_in_routers"].name == "no_models_in_routers" + assert std.custom[0].id == "no-print" + assert std.custom[0].languages == ["python"] + assert std.waivers[0].rule == "no_models_in_routers" + + +def test_empty_yaml_yields_default_standard() -> None: + std = ConventionsStandard.parse_yaml("") + assert std == ConventionsStandard() + assert std.version == 1 + + +def test_unknown_rule_level_raises_parse_error() -> None: + with pytest.raises(ConventionsParseError): + ConventionsStandard.parse_yaml( + "rules:\n no_models_in_routers: { level: explode }\n" + ) + + +def test_malformed_yaml_raises_parse_error() -> None: + with pytest.raises(ConventionsParseError): + ConventionsStandard.parse_yaml("modules: [unterminated\n") + + +def test_non_mapping_top_level_raises_parse_error() -> None: + with pytest.raises(ConventionsParseError): + ConventionsStandard.parse_yaml("- just\n- a\n- list\n") + + +def test_unknown_definition_kind_in_forbidden_raises() -> None: + with pytest.raises(ConventionsParseError): + ConventionsStandard.parse_yaml( + "modules:\n - path: x\n purpose: y\n forbidden: [wizard]\n" + ) + + +def test_builtin_rules_cover_the_org_defaults() -> None: + assert BUILTIN_RULES["no_models_in_routers"] == "block" + assert BUILTIN_RULES["no_helpers_in_routers"] == "block" + assert BUILTIN_RULES["no_lint_suppressions"] == "block" + assert BUILTIN_RULES["no_inline_comments"] == "warn" + + +def test_models_construct_directly() -> None: + mod = Module(path="app/services", purpose="logic", forbidden=["route"]) + assert mod.forbidden == ["route"] + rule = Rule(name="no_print", level="warn") + assert rule.level == "warn" + custom = CustomRule(id="x", pattern="y", message="z", level="block") + assert custom.languages == [] + waiver = Waiver(path="a.py", rule="no_models_in_routers", reason="r") + assert waiver.path == "a.py" diff --git a/tests/unit/foundation/policy/conventions/test_effective_map.py b/tests/unit/foundation/policy/conventions/test_effective_map.py new file mode 100644 index 00000000..b27369be --- /dev/null +++ b/tests/unit/foundation/policy/conventions/test_effective_map.py @@ -0,0 +1,89 @@ +"""Effective-map merge tests: auto-derived defaults overlaid by the file.""" + +from __future__ import annotations + +from roboco.foundation.policy.conventions.effective_map import effective_map +from roboco.foundation.policy.conventions.models import ( + ConventionsStandard, + CustomRule, + Module, + Rule, + Waiver, +) + + +def test_effective_map_applies_builtin_rules_when_file_absent() -> None: + eff = effective_map(ConventionsStandard(), None) + assert eff.rules["no_models_in_routers"].level == "block" + assert eff.rules["no_inline_comments"].level == "warn" + + +def test_file_module_overrides_derived_by_path() -> None: + derived = ConventionsStandard( + modules=[Module(path="app/routers", purpose="routes")] + ) + file = ConventionsStandard( + modules=[Module(path="app/routers", purpose="routes", forbidden=["model"])] + ) + eff = effective_map(derived, file) + assert len(eff.modules) == 1 + assert eff.modules[0].forbidden == ["model"] + + +def test_file_module_appends_new_path() -> None: + derived = ConventionsStandard( + modules=[Module(path="app/routers", purpose="routes")] + ) + file = ConventionsStandard(modules=[Module(path="app/models", purpose="models")]) + eff = effective_map(derived, file) + assert [m.path for m in eff.modules] == ["app/routers", "app/models"] + + +def test_file_rule_overrides_builtin_level() -> None: + file = ConventionsStandard( + rules={"no_inline_comments": Rule(name="no_inline_comments", level="block")} + ) + eff = effective_map(ConventionsStandard(), file) + assert eff.rules["no_inline_comments"].level == "block" + + +def test_derived_rule_overrides_builtin_then_file_overrides_derived() -> None: + derived = ConventionsStandard( + rules={"no_inline_comments": Rule(name="no_inline_comments", level="block")} + ) + eff_no_file = effective_map(derived, None) + assert eff_no_file.rules["no_inline_comments"].level == "block" + file = ConventionsStandard( + rules={"no_inline_comments": Rule(name="no_inline_comments", level="warn")} + ) + eff = effective_map(derived, file) + assert eff.rules["no_inline_comments"].level == "warn" + + +def test_languages_are_unioned() -> None: + derived = ConventionsStandard(languages=["python"]) + file = ConventionsStandard(languages=["python", "typescript"]) + eff = effective_map(derived, file) + assert eff.languages == ["python", "typescript"] + + +def test_file_custom_and_waivers_replace_derived() -> None: + derived = ConventionsStandard( + custom=[CustomRule(id="d", pattern="d", message="d", level="warn")], + waivers=[Waiver(path="d.py", rule="no_models_in_routers", reason="d")], + ) + file = ConventionsStandard( + custom=[CustomRule(id="f", pattern="f", message="f", level="block")], + waivers=[Waiver(path="f.py", rule="no_helpers_in_routers", reason="f")], + ) + eff = effective_map(derived, file) + assert [c.id for c in eff.custom] == ["f"] + assert [w.path for w in eff.waivers] == ["f.py"] + + +def test_file_none_keeps_derived_custom_and_waivers() -> None: + derived = ConventionsStandard( + custom=[CustomRule(id="d", pattern="d", message="d", level="warn")], + ) + eff = effective_map(derived, None) + assert [c.id for c in eff.custom] == ["d"] diff --git a/uv.lock b/uv.lock index 636431c4..76aa9d72 100644 --- a/uv.lock +++ b/uv.lock @@ -3003,6 +3003,7 @@ dependencies = [ { name = "python-jose", extra = ["cryptography"] }, { name = "python-multipart" }, { name = "python-toon" }, + { name = "pyyaml" }, { name = "redis" }, { name = "sqlalchemy", extra = ["asyncio"] }, { name = "sse-starlette" }, @@ -3035,6 +3036,7 @@ dev = [ { name = "ruff" }, { name = "types-passlib" }, { name = "types-python-jose" }, + { name = "types-pyyaml" }, { name = "vulture" }, { name = "xenon" }, ] @@ -3086,6 +3088,7 @@ requires-dist = [ { name = "python-jose", extras = ["cryptography"] }, { name = "python-multipart" }, { name = "python-toon" }, + { name = "pyyaml" }, { name = "radon", marker = "extra == 'dev'" }, { name = "redis" }, { name = "rich", marker = "extra == 'dev'" }, @@ -3098,6 +3101,7 @@ requires-dist = [ { name = "tomli-w" }, { name = "types-passlib", marker = "extra == 'dev'" }, { name = "types-python-jose", marker = "extra == 'dev'" }, + { name = "types-pyyaml", marker = "extra == 'dev'" }, { name = "uvicorn", extras = ["standard"] }, { name = "vulture", marker = "extra == 'dev'" }, { name = "websockets" }, @@ -3721,6 +3725,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/83/df2b34e64f0a674935d718471cf10fb392a7e5bdb0e9e7c739885b62d274/types_python_jose-3.5.0.20260408-py3-none-any.whl", hash = "sha256:968d8a8eac1ff9da249d6335a2bb9f82288d59ba23afe91fcc2662eb9f485e2a", size = 14694, upload-time = "2026-04-08T04:34:09.747Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From f8c4e7084c402dd2d36a69ab980418643c7ab911 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 03:37:10 +0200 Subject: [PATCH 02/29] feat(conventions): tree-sitter Python classifier + placement checks --- pyproject.toml | 8 ++ roboco/conventions/__init__.py | 23 ++++ roboco/conventions/classify_python.py | 101 ++++++++++++++++++ roboco/conventions/findings.py | 27 +++++ roboco/conventions/grammars.py | 57 ++++++++++ roboco/conventions/placement.py | 70 ++++++++++++ .../unit/conventions/test_classify_python.py | 81 ++++++++++++++ tests/unit/conventions/test_placement.py | 93 ++++++++++++++++ uv.lock | 80 ++++++++++++++ 9 files changed, 540 insertions(+) create mode 100644 roboco/conventions/__init__.py create mode 100644 roboco/conventions/classify_python.py create mode 100644 roboco/conventions/findings.py create mode 100644 roboco/conventions/grammars.py create mode 100644 roboco/conventions/placement.py create mode 100644 tests/unit/conventions/test_classify_python.py create mode 100644 tests/unit/conventions/test_placement.py diff --git a/pyproject.toml b/pyproject.toml index 51e852d8..5fe39734 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,10 @@ dependencies = [ "cryptography", # utils/crypto.py — Fernet-encrypted project git tokens "packaging", # services/toolchain.py — PEP 440 requires-python resolution "pyyaml", # foundation/policy/conventions — .roboco/conventions.yml parse + # Conventions validator (roboco.conventions) — tree-sitter ASTs, Python + TS + "tree-sitter>=0.22", + "tree-sitter-python", + "tree-sitter-typescript", "claude-agent-sdk>=0.2.105", ] @@ -170,6 +174,10 @@ select = [ # bottom of policy/lifecycle.py at module-load time, so the validators must # defer their inverse imports until call time to avoid a cycle. "roboco/foundation/_validate_lifecycle.py" = ["PLC0415"] +# The conventions validator loads tree-sitter grammars lazily, per language, so +# the package imports without tree-sitter present and a missing grammar fails +# loud at call time (GrammarUnavailable) instead of crashing the import. +"roboco/conventions/grammars.py" = ["PLC0415"] # Test fixtures that reload modules to test env-var-at-import-time behavior. # ARG002: ApiClient-subclassing fakes must keep the superclass parameter names # for mypy's override check, so unused override-stub args can't be renamed. diff --git a/roboco/conventions/__init__.py b/roboco/conventions/__init__.py new file mode 100644 index 00000000..befe285a --- /dev/null +++ b/roboco/conventions/__init__.py @@ -0,0 +1,23 @@ +"""The ``roboco-conventions`` validator: tree-sitter placement + hygiene checks. + +A single Python CLI (``python -m roboco.conventions``) classifies each changed +definition and flags forbidden placements, hygiene violations, and custom-rule +matches against a project's effective conventions map. Precision over recall, +fail loud: an ambiguous definition abstains; a validator that cannot run exits +non-zero so the gate blocks rather than silently passing. +""" + +from __future__ import annotations + +from .classify_python import classify_definitions +from .findings import Finding +from .grammars import GrammarUnavailable, get_parser +from .placement import check_placement + +__all__ = [ + "Finding", + "GrammarUnavailable", + "check_placement", + "classify_definitions", + "get_parser", +] diff --git a/roboco/conventions/classify_python.py b/roboco/conventions/classify_python.py new file mode 100644 index 00000000..49b5d900 --- /dev/null +++ b/roboco/conventions/classify_python.py @@ -0,0 +1,101 @@ +"""Classify each top-level Python definition into an architectural *kind*. + +Precision over recall: a definition that is not clearly a model, route, or +helper abstains to ``other`` (no finding) so a ``block`` gate can never strand +a task on a false positive. Only module-level definitions are considered. + +- ``model`` — a class extending ``BaseModel`` / ``DeclarativeBase`` / ``Base``. +- ``route`` — a function decorated with ``@router.*`` / ``@app.*`` or any + ``@.get|post|put|delete|patch``. +- ``helper`` — any other module-level function. +- ``other`` — anything ambiguous (abstain). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from roboco.foundation.policy.conventions.models import DefinitionKind + +from .grammars import get_parser + +if TYPE_CHECKING: + from tree_sitter import Node + +Definition = tuple[str, int, DefinitionKind] + +_MODEL_BASES = frozenset({"BaseModel", "DeclarativeBase", "Base"}) +_HTTP_METHODS = frozenset({"get", "post", "put", "delete", "patch"}) +_ROUTER_OBJECTS = frozenset({"router", "app"}) + + +def classify_definitions(source: bytes) -> list[Definition]: + """Return ``(name, line, kind)`` for each top-level definition in ``source``.""" + root = get_parser("python").parse(source).root_node + results: list[Definition] = [] + for node in root.children: + classified = _classify_top_level(node) + if classified is not None: + results.append(classified) + return results + + +def _classify_top_level(node: Node) -> Definition | None: + if node.type == "decorated_definition": + inner = node.child_by_field_name("definition") + decorators = [c for c in node.children if c.type == "decorator"] + return _classify_def(inner, decorators) if inner is not None else None + if node.type in ("class_definition", "function_definition"): + return _classify_def(node, []) + return None + + +def _classify_def(node: Node, decorators: list[Node]) -> Definition | None: + name = _text(node.child_by_field_name("name")) + if not name: + return None + line = node.start_point[0] + 1 + if node.type == "class_definition": + kind: DefinitionKind = "model" if _is_model_class(node) else "other" + elif _is_route(decorators): + kind = "route" + else: + kind = "helper" + return (name, line, kind) + + +def _is_model_class(class_node: Node) -> bool: + supers = class_node.child_by_field_name("superclasses") + if supers is None: + return False + return any(_base_name(child) in _MODEL_BASES for child in supers.children) + + +def _base_name(node: Node) -> str: + if node.type == "identifier": + return _text(node) + if node.type == "attribute": + return _text(node.child_by_field_name("attribute")) + return "" + + +def _is_route(decorators: list[Node]) -> bool: + return any(_decorator_is_route(d) for d in decorators) + + +def _decorator_is_route(decorator: Node) -> bool: + expr = next((c for c in decorator.children if c.type != "@"), None) + if expr is not None and expr.type == "call": + expr = expr.child_by_field_name("function") + if expr is None or expr.type != "attribute": + return False + obj = expr.child_by_field_name("object") + method = _text(expr.child_by_field_name("attribute")) + obj_name = _base_name(obj) if obj is not None else "" + return obj_name in _ROUTER_OBJECTS or method in _HTTP_METHODS + + +def _text(node: Node | None) -> str: + if node is None or node.text is None: + return "" + return node.text.decode() diff --git a/roboco/conventions/findings.py b/roboco/conventions/findings.py new file mode 100644 index 00000000..30678c9c --- /dev/null +++ b/roboco/conventions/findings.py @@ -0,0 +1,27 @@ +"""A single convention finding — the validator's unit of output. + +One ``Finding`` per violation, serialized as one JSON line by the CLI. The +gateway gates parse these lines and block on any ``level == "block"`` finding. +""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass + + +@dataclass(frozen=True) +class Finding: + """One placement / hygiene / custom-rule violation on a changed file.""" + + file: str + line: int + kind: str | None + rule: str + level: str + message: str + fix_hint: str + + def as_json(self) -> str: + """Render the finding as a single compact JSON object (one line).""" + return json.dumps(asdict(self), separators=(",", ":")) diff --git a/roboco/conventions/grammars.py b/roboco/conventions/grammars.py new file mode 100644 index 00000000..4d3df243 --- /dev/null +++ b/roboco/conventions/grammars.py @@ -0,0 +1,57 @@ +"""Lazy tree-sitter parser construction, one per language. + +Grammars are loaded on first use and cached. A missing grammar raises +``GrammarUnavailable`` so the runner can fail loud (the gate blocks with +"validator could not run" rather than silently passing). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from tree_sitter import Language, Parser + + +class GrammarUnavailable(RuntimeError): + """Raised when a language's tree-sitter grammar cannot be loaded.""" + + def __init__(self, language: str) -> None: + super().__init__(f"tree-sitter grammar unavailable for {language!r}") + self.language = language + + +_PARSERS: dict[str, Parser] = {} + + +def _load_language(language: str) -> Language: + from tree_sitter import Language + + try: + if language == "python": + import tree_sitter_python + + return Language(tree_sitter_python.language()) + if language == "typescript": + import tree_sitter_typescript + + return Language(tree_sitter_typescript.language_typescript()) + if language == "tsx": + import tree_sitter_typescript + + return Language(tree_sitter_typescript.language_tsx()) + except ImportError as exc: + raise GrammarUnavailable(language) from exc + raise GrammarUnavailable(language) + + +def get_parser(language: str) -> Parser: + """Return a cached parser for ``language`` (``python``/``typescript``/``tsx``).""" + cached = _PARSERS.get(language) + if cached is not None: + return cached + from tree_sitter import Parser + + parser = Parser(_load_language(language)) + _PARSERS[language] = parser + return parser diff --git a/roboco/conventions/placement.py b/roboco/conventions/placement.py new file mode 100644 index 00000000..54fe29d1 --- /dev/null +++ b/roboco/conventions/placement.py @@ -0,0 +1,70 @@ +"""Placement checks: flag a definition whose kind is forbidden in its module. + +A file is mapped to the most specific module whose ``path`` is a directory +prefix of it; any classified definition whose kind is in that module's +``forbidden`` list becomes a ``Finding``. The rule name follows the org +convention ``no_s_in_`` so it lines up with ``BUILTIN_RULES`` +(e.g. a model in ``app/routers`` → ``no_models_in_routers``). +""" + +from __future__ import annotations + +from roboco.foundation.policy.conventions.models import ( + ConventionsStandard, + DefinitionKind, + Module, +) + +from .findings import Finding + +Definition = tuple[str, int, DefinitionKind] + + +def check_placement( + rel_path: str, defs: list[Definition], standard: ConventionsStandard +) -> list[Finding]: + """Return placement findings for ``defs`` in the file at ``rel_path``.""" + module = _module_for(rel_path, standard) + if module is None or not module.forbidden: + return [] + return [ + _finding(rel_path, d, module, standard) + for d in defs + if d[2] in module.forbidden + ] + + +def _module_for(rel_path: str, standard: ConventionsStandard) -> Module | None: + matches = [m for m in standard.modules if _path_in_module(rel_path, m.path)] + return max(matches, key=lambda m: len(m.path)) if matches else None + + +def _path_in_module(rel_path: str, module_path: str) -> bool: + stem = module_path.rstrip("/") + return rel_path == stem or rel_path.startswith(stem + "/") + + +def _finding( + rel_path: str, definition: Definition, module: Module, standard: ConventionsStandard +) -> Finding: + name, line, kind = definition + rule = f"no_{kind}s_in_{_module_leaf(module.path)}" + level = standard.rules[rule].level if rule in standard.rules else "block" + return Finding( + file=rel_path, + line=line, + kind=kind, + rule=rule, + level=level, + message=( + f"{kind} '{name}' defined in {module.path} — " + f"{kind}s are forbidden here ({module.purpose})" + ), + fix_hint=( + f"move '{name}' out of {module.path} into the module that owns {kind}s" + ), + ) + + +def _module_leaf(module_path: str) -> str: + return module_path.rstrip("/").rsplit("/", 1)[-1] diff --git a/tests/unit/conventions/test_classify_python.py b/tests/unit/conventions/test_classify_python.py new file mode 100644 index 00000000..12367cf9 --- /dev/null +++ b/tests/unit/conventions/test_classify_python.py @@ -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")] diff --git a/tests/unit/conventions/test_placement.py b/tests/unit/conventions/test_placement.py new file mode 100644 index 00000000..259bdac7 --- /dev/null +++ b/tests/unit/conventions/test_placement.py @@ -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 check_placement +from roboco.foundation.policy.conventions.models import ( + ConventionsStandard, + Module, + Rule, +) + +_MODEL_LINE = 2 +_DEFS = [("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", + } diff --git a/uv.lock b/uv.lock index 76aa9d72..de355f5c 100644 --- a/uv.lock +++ b/uv.lock @@ -3011,6 +3011,9 @@ dependencies = [ { name = "tenacity" }, { name = "tiktoken" }, { name = "tomli-w" }, + { name = "tree-sitter" }, + { name = "tree-sitter-python" }, + { name = "tree-sitter-typescript" }, { name = "uvicorn", extra = ["standard"] }, { name = "websockets" }, ] @@ -3099,6 +3102,9 @@ requires-dist = [ { name = "tenacity" }, { name = "tiktoken" }, { name = "tomli-w" }, + { name = "tree-sitter", specifier = ">=0.22" }, + { name = "tree-sitter-python" }, + { name = "tree-sitter-typescript" }, { name = "types-passlib", marker = "extra == 'dev'" }, { name = "types-python-jose", marker = "extra == 'dev'" }, { name = "types-pyyaml", marker = "extra == 'dev'" }, @@ -3695,6 +3701,80 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, ] +[[package]] +name = "tree-sitter" +version = "0.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/7c/0350cfc47faadc0d3cf7d8237a4e34032b3014ddf4a12ded9933e1648b55/tree-sitter-0.25.2.tar.gz", hash = "sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20", size = 177961, upload-time = "2025-09-25T17:37:59.751Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/d4/f7ffb855cb039b7568aba4911fbe42e4c39c0e4398387c8e0d8251489992/tree_sitter-0.25.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72a510931c3c25f134aac2daf4eb4feca99ffe37a35896d7150e50ac3eee06c7", size = 146749, upload-time = "2025-09-25T17:37:16.475Z" }, + { url = "https://files.pythonhosted.org/packages/9a/58/f8a107f9f89700c0ab2930f1315e63bdedccbb5fd1b10fcbc5ebadd54ac8/tree_sitter-0.25.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44488e0e78146f87baaa009736886516779253d6d6bac3ef636ede72bc6a8234", size = 137766, upload-time = "2025-09-25T17:37:18.138Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/357158d39f01699faea466e8fd5a849f5a30252c68414bddc20357a9ac79/tree_sitter-0.25.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2f8e7d6b2f8489d4a9885e3adcaef4bc5ff0a275acd990f120e29c4ab3395c5", size = 599809, upload-time = "2025-09-25T17:37:19.169Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a4/68ae301626f2393a62119481cb660eb93504a524fc741a6f1528a4568cf6/tree_sitter-0.25.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20b570690f87f1da424cd690e51cc56728d21d63f4abd4b326d382a30353acc7", size = 627676, upload-time = "2025-09-25T17:37:20.715Z" }, + { url = "https://files.pythonhosted.org/packages/69/fe/4c1bef37db5ca8b17ca0b3070f2dff509468a50b3af18f17665adcab42b9/tree_sitter-0.25.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a0ec41b895da717bc218a42a3a7a0bfcfe9a213d7afaa4255353901e0e21f696", size = 624281, upload-time = "2025-09-25T17:37:21.823Z" }, + { url = "https://files.pythonhosted.org/packages/d4/30/3283cb7fa251cae2a0bf8661658021a789810db3ab1b0569482d4a3671fd/tree_sitter-0.25.2-cp310-cp310-win_amd64.whl", hash = "sha256:7712335855b2307a21ae86efe949c76be36c6068d76df34faa27ce9ee40ff444", size = 127295, upload-time = "2025-09-25T17:37:22.977Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/ceb05e6de281aebe82b68662890619580d4ffe09283ebd2ceabcf5df7b4a/tree_sitter-0.25.2-cp310-cp310-win_arm64.whl", hash = "sha256:a925364eb7fbb9cdce55a9868f7525a1905af512a559303bd54ef468fd88cb37", size = 113991, upload-time = "2025-09-25T17:37:23.854Z" }, + { url = "https://files.pythonhosted.org/packages/7c/22/88a1e00b906d26fa8a075dd19c6c3116997cb884bf1b3c023deb065a344d/tree_sitter-0.25.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8ca72d841215b6573ed0655b3a5cd1133f9b69a6fa561aecad40dca9029d75b", size = 146752, upload-time = "2025-09-25T17:37:24.775Z" }, + { url = "https://files.pythonhosted.org/packages/57/1c/22cc14f3910017b7a76d7358df5cd315a84fe0c7f6f7b443b49db2e2790d/tree_sitter-0.25.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cc0351cfe5022cec5a77645f647f92a936b38850346ed3f6d6babfbeeeca4d26", size = 137765, upload-time = "2025-09-25T17:37:26.103Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0c/d0de46ded7d5b34631e0f630d9866dab22d3183195bf0f3b81de406d6622/tree_sitter-0.25.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1799609636c0193e16c38f366bda5af15b1ce476df79ddaae7dd274df9e44266", size = 604643, upload-time = "2025-09-25T17:37:27.398Z" }, + { url = "https://files.pythonhosted.org/packages/34/38/b735a58c1c2f60a168a678ca27b4c1a9df725d0bf2d1a8a1c571c033111e/tree_sitter-0.25.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e65ae456ad0d210ee71a89ee112ac7e72e6c2e5aac1b95846ecc7afa68a194c", size = 632229, upload-time = "2025-09-25T17:37:28.463Z" }, + { url = "https://files.pythonhosted.org/packages/32/f6/cda1e1e6cbff5e28d8433578e2556d7ba0b0209d95a796128155b97e7693/tree_sitter-0.25.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:49ee3c348caa459244ec437ccc7ff3831f35977d143f65311572b8ba0a5f265f", size = 629861, upload-time = "2025-09-25T17:37:29.593Z" }, + { url = "https://files.pythonhosted.org/packages/f9/19/427e5943b276a0dd74c2a1f1d7a7393443f13d1ee47dedb3f8127903c080/tree_sitter-0.25.2-cp311-cp311-win_amd64.whl", hash = "sha256:56ac6602c7d09c2c507c55e58dc7026b8988e0475bd0002f8a386cce5e8e8adc", size = 127304, upload-time = "2025-09-25T17:37:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d9/eef856dc15f784d85d1397a17f3ee0f82df7778efce9e1961203abfe376a/tree_sitter-0.25.2-cp311-cp311-win_arm64.whl", hash = "sha256:b3d11a3a3ac89bb8a2543d75597f905a9926f9c806f40fcca8242922d1cc6ad5", size = 113990, upload-time = "2025-09-25T17:37:31.852Z" }, + { url = "https://files.pythonhosted.org/packages/3c/9e/20c2a00a862f1c2897a436b17edb774e831b22218083b459d0d081c9db33/tree_sitter-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960", size = 146941, upload-time = "2025-09-25T17:37:34.813Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/8512e2062e652a1016e840ce36ba1cc33258b0dcc4e500d8089b4054afec/tree_sitter-0.25.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c", size = 137699, upload-time = "2025-09-25T17:37:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/47/8a/d48c0414db19307b0fb3bb10d76a3a0cbe275bb293f145ee7fba2abd668e/tree_sitter-0.25.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99", size = 607125, upload-time = "2025-09-25T17:37:37.725Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/b95f545e9fc5001b8a78636ef942a4e4e536580caa6a99e73dd0a02e87aa/tree_sitter-0.25.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9", size = 635418, upload-time = "2025-09-25T17:37:38.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/4d/b734bde3fb6f3513a010fa91f1f2875442cdc0382d6a949005cd84563d8f/tree_sitter-0.25.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac", size = 631250, upload-time = "2025-09-25T17:37:40.039Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/5f654994f36d10c64d50a192239599fcae46677491c8dd53e7579c35a3e3/tree_sitter-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897", size = 127156, upload-time = "2025-09-25T17:37:41.132Z" }, + { url = "https://files.pythonhosted.org/packages/67/23/148c468d410efcf0a9535272d81c258d840c27b34781d625f1f627e2e27d/tree_sitter-0.25.2-cp312-cp312-win_arm64.whl", hash = "sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5", size = 113984, upload-time = "2025-09-25T17:37:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/8c/67/67492014ce32729b63d7ef318a19f9cfedd855d677de5773476caf771e96/tree_sitter-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0628671f0de69bb279558ef6b640bcfc97864fe0026d840f872728a86cd6b6cd", size = 146926, upload-time = "2025-09-25T17:37:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/a278b15e6b263e86c5e301c82a60923fa7c59d44f78d7a110a89a413e640/tree_sitter-0.25.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f5ddcd3e291a749b62521f71fc953f66f5fd9743973fd6dd962b092773569601", size = 137712, upload-time = "2025-09-25T17:37:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/423bba15d2bf6473ba67846ba5244b988cd97a4b1ea2b146822162256794/tree_sitter-0.25.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd88fbb0f6c3a0f28f0a68d72df88e9755cf5215bae146f5a1bdc8362b772053", size = 607873, upload-time = "2025-09-25T17:37:45.477Z" }, + { url = "https://files.pythonhosted.org/packages/ed/4c/b430d2cb43f8badfb3a3fa9d6cd7c8247698187b5674008c9d67b2a90c8e/tree_sitter-0.25.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b878e296e63661c8e124177cc3084b041ba3f5936b43076d57c487822426f614", size = 636313, upload-time = "2025-09-25T17:37:46.68Z" }, + { url = "https://files.pythonhosted.org/packages/9d/27/5f97098dbba807331d666a0997662e82d066e84b17d92efab575d283822f/tree_sitter-0.25.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d77605e0d353ba3fe5627e5490f0fbfe44141bafa4478d88ef7954a61a848dae", size = 631370, upload-time = "2025-09-25T17:37:47.993Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/87caaed663fabc35e18dc704cd0e9800a0ee2f22bd18b9cbe7c10799895d/tree_sitter-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:463c032bd02052d934daa5f45d183e0521ceb783c2548501cf034b0beba92c9b", size = 127157, upload-time = "2025-09-25T17:37:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/d5/23/f8467b408b7988aff4ea40946a4bd1a2c1a73d17156a9d039bbaff1e2ceb/tree_sitter-0.25.2-cp313-cp313-win_arm64.whl", hash = "sha256:b3f63a1796886249bd22c559a5944d64d05d43f2be72961624278eff0dcc5cb8", size = 113975, upload-time = "2025-09-25T17:37:49.922Z" }, + { url = "https://files.pythonhosted.org/packages/07/e3/d9526ba71dfbbe4eba5e51d89432b4b333a49a1e70712aa5590cd22fc74f/tree_sitter-0.25.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65d3c931013ea798b502782acab986bbf47ba2c452610ab0776cf4a8ef150fc0", size = 146776, upload-time = "2025-09-25T17:37:50.898Z" }, + { url = "https://files.pythonhosted.org/packages/42/97/4bd4ad97f85a23011dd8a535534bb1035c4e0bac1234d58f438e15cff51f/tree_sitter-0.25.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bda059af9d621918efb813b22fb06b3fe00c3e94079c6143fcb2c565eb44cb87", size = 137732, upload-time = "2025-09-25T17:37:51.877Z" }, + { url = "https://files.pythonhosted.org/packages/b6/19/1e968aa0b1b567988ed522f836498a6a9529a74aab15f09dd9ac1e41f505/tree_sitter-0.25.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eac4e8e4c7060c75f395feec46421eb61212cb73998dbe004b7384724f3682ab", size = 609456, upload-time = "2025-09-25T17:37:52.925Z" }, + { url = "https://files.pythonhosted.org/packages/48/b6/cf08f4f20f4c9094006ef8828555484e842fc468827ad6e56011ab668dbd/tree_sitter-0.25.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:260586381b23be33b6191a07cea3d44ecbd6c01aa4c6b027a0439145fcbc3358", size = 636772, upload-time = "2025-09-25T17:37:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/57/e2/d42d55bf56360987c32bc7b16adb06744e425670b823fb8a5786a1cea991/tree_sitter-0.25.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d2ee1acbacebe50ba0f85fff1bc05e65d877958f00880f49f9b2af38dce1af0", size = 631522, upload-time = "2025-09-25T17:37:55.833Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/af9604ebe275a9345d88c3ace0cf2a1341aa3f8ef49dd9fc11662132df8a/tree_sitter-0.25.2-cp314-cp314-win_amd64.whl", hash = "sha256:4973b718fcadfb04e59e746abfbb0288694159c6aeecd2add59320c03368c721", size = 130864, upload-time = "2025-09-25T17:37:57.453Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6e/e64621037357acb83d912276ffd30a859ef117f9c680f2e3cb955f47c680/tree_sitter-0.25.2-cp314-cp314-win_arm64.whl", hash = "sha256:b8d4429954a3beb3e844e2872610d2a4800ba4eb42bb1990c6a4b1949b18459f", size = 117470, upload-time = "2025-09-25T17:37:58.431Z" }, +] + +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/8b/c992ff0e768cb6768d5c96234579bf8842b3a633db641455d86dd30d5dac/tree_sitter_python-0.25.0.tar.gz", hash = "sha256:b13e090f725f5b9c86aa455a268553c65cadf325471ad5b65cd29cac8a1a68ac", size = 159845, upload-time = "2025-09-11T06:47:58.159Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/64/a4e503c78a4eb3ac46d8e72a29c1b1237fa85238d8e972b063e0751f5a94/tree_sitter_python-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:14a79a47ddef72f987d5a2c122d148a812169d7484ff5c75a3db9609d419f361", size = 73790, upload-time = "2025-09-11T06:47:47.652Z" }, + { url = "https://files.pythonhosted.org/packages/e6/1d/60d8c2a0cc63d6ec4ba4e99ce61b802d2e39ef9db799bdf2a8f932a6cd4b/tree_sitter_python-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:480c21dbd995b7fe44813e741d71fed10ba695e7caab627fb034e3828469d762", size = 76691, upload-time = "2025-09-11T06:47:49.038Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/d9b0b67d037922d60cbe0359e0c86457c2da721bc714381a63e2c8e35eba/tree_sitter_python-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:86f118e5eecad616ecdb81d171a36dde9bef5a0b21ed71ea9c3e390813c3baf5", size = 108133, upload-time = "2025-09-11T06:47:50.499Z" }, + { url = "https://files.pythonhosted.org/packages/40/bd/bf4787f57e6b2860f3f1c8c62f045b39fb32d6bac4b53d7a9e66de968440/tree_sitter_python-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be71650ca2b93b6e9649e5d65c6811aad87a7614c8c1003246b303f6b150f61b", size = 110603, upload-time = "2025-09-11T06:47:51.985Z" }, + { url = "https://files.pythonhosted.org/packages/5d/25/feff09f5c2f32484fbce15db8b49455c7572346ce61a699a41972dea7318/tree_sitter_python-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e6d5b5799628cc0f24691ab2a172a8e676f668fe90dc60468bee14084a35c16d", size = 108998, upload-time = "2025-09-11T06:47:53.046Z" }, + { url = "https://files.pythonhosted.org/packages/75/69/4946da3d6c0df316ccb938316ce007fb565d08f89d02d854f2d308f0309f/tree_sitter_python-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:71959832fc5d9642e52c11f2f7d79ae520b461e63334927e93ca46cd61cd9683", size = 107268, upload-time = "2025-09-11T06:47:54.388Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a2/996fc2dfa1076dc460d3e2f3c75974ea4b8f02f6bc925383aaae519920e8/tree_sitter_python-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:9bcde33f18792de54ee579b00e1b4fe186b7926825444766f849bf7181793a76", size = 76073, upload-time = "2025-09-11T06:47:55.773Z" }, + { url = "https://files.pythonhosted.org/packages/07/19/4b5569d9b1ebebb5907d11554a96ef3fa09364a30fcfabeff587495b512f/tree_sitter_python-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:0fbf6a3774ad7e89ee891851204c2e2c47e12b63a5edbe2e9156997731c128bb", size = 74169, upload-time = "2025-09-11T06:47:56.747Z" }, +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/fc/bb52958f7e399250aee093751e9373a6311cadbe76b6e0d109b853757f35/tree_sitter_typescript-0.23.2.tar.gz", hash = "sha256:7b167b5827c882261cb7a50dfa0fb567975f9b315e87ed87ad0a0a3aedb3834d", size = 773053, upload-time = "2024-11-11T02:36:11.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/95/4c00680866280e008e81dd621fd4d3f54aa3dad1b76b857a19da1b2cc426/tree_sitter_typescript-0.23.2-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3cd752d70d8e5371fdac6a9a4df9d8924b63b6998d268586f7d374c9fba2a478", size = 286677, upload-time = "2024-11-11T02:35:58.839Z" }, + { url = "https://files.pythonhosted.org/packages/8f/2f/1f36fda564518d84593f2740d5905ac127d590baf5c5753cef2a88a89c15/tree_sitter_typescript-0.23.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:c7cc1b0ff5d91bac863b0e38b1578d5505e718156c9db577c8baea2557f66de8", size = 302008, upload-time = "2024-11-11T02:36:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/96/2d/975c2dad292aa9994f982eb0b69cc6fda0223e4b6c4ea714550477d8ec3a/tree_sitter_typescript-0.23.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b1eed5b0b3a8134e86126b00b743d667ec27c63fc9de1b7bb23168803879e31", size = 351987, upload-time = "2024-11-11T02:36:02.669Z" }, + { url = "https://files.pythonhosted.org/packages/49/d1/a71c36da6e2b8a4ed5e2970819b86ef13ba77ac40d9e333cb17df6a2c5db/tree_sitter_typescript-0.23.2-cp39-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e96d36b85bcacdeb8ff5c2618d75593ef12ebaf1b4eace3477e2bdb2abb1752c", size = 344960, upload-time = "2024-11-11T02:36:04.443Z" }, + { url = "https://files.pythonhosted.org/packages/7f/cb/f57b149d7beed1a85b8266d0c60ebe4c46e79c9ba56bc17b898e17daf88e/tree_sitter_typescript-0.23.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8d4f0f9bcb61ad7b7509d49a1565ff2cc363863644a234e1e0fe10960e55aea0", size = 340245, upload-time = "2024-11-11T02:36:06.473Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ab/dd84f0e2337296a5f09749f7b5483215d75c8fa9e33738522e5ed81f7254/tree_sitter_typescript-0.23.2-cp39-abi3-win_amd64.whl", hash = "sha256:3f730b66396bc3e11811e4465c41ee45d9e9edd6de355a58bbbc49fa770da8f9", size = 278015, upload-time = "2024-11-11T02:36:07.631Z" }, + { url = "https://files.pythonhosted.org/packages/9f/e4/81f9a935789233cf412a0ed5fe04c883841d2c8fb0b7e075958a35c65032/tree_sitter_typescript-0.23.2-cp39-abi3-win_arm64.whl", hash = "sha256:05db58f70b95ef0ea126db5560f3775692f609589ed6f8dd0af84b7f19f1cbb7", size = 274052, upload-time = "2024-11-11T02:36:09.514Z" }, +] + [[package]] name = "types-passlib" version = "1.7.7.20260211" From 50e188166c6d18e21f190a482340361a51751f24 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 03:48:16 +0200 Subject: [PATCH 03/29] feat(conventions): TS classifier, hygiene/custom checks, runner + CLI --- roboco/conventions/__init__.py | 7 + roboco/conventions/__main__.py | 73 ++++++++++ roboco/conventions/classify_ts.py | 159 +++++++++++++++++++++ roboco/conventions/custom.py | 51 +++++++ roboco/conventions/hygiene.py | 105 ++++++++++++++ roboco/conventions/runner.py | 77 ++++++++++ tests/unit/conventions/test_classify_ts.py | 50 +++++++ tests/unit/conventions/test_cli.py | 75 ++++++++++ tests/unit/conventions/test_custom.py | 49 +++++++ tests/unit/conventions/test_hygiene.py | 67 +++++++++ tests/unit/conventions/test_runner.py | 78 ++++++++++ 11 files changed, 791 insertions(+) create mode 100644 roboco/conventions/__main__.py create mode 100644 roboco/conventions/classify_ts.py create mode 100644 roboco/conventions/custom.py create mode 100644 roboco/conventions/hygiene.py create mode 100644 roboco/conventions/runner.py create mode 100644 tests/unit/conventions/test_classify_ts.py create mode 100644 tests/unit/conventions/test_cli.py create mode 100644 tests/unit/conventions/test_custom.py create mode 100644 tests/unit/conventions/test_hygiene.py create mode 100644 tests/unit/conventions/test_runner.py diff --git a/roboco/conventions/__init__.py b/roboco/conventions/__init__.py index befe285a..68532be7 100644 --- a/roboco/conventions/__init__.py +++ b/roboco/conventions/__init__.py @@ -10,14 +10,21 @@ from __future__ import annotations from .classify_python import classify_definitions +from .custom import check_custom from .findings import Finding from .grammars import GrammarUnavailable, get_parser +from .hygiene import check_hygiene from .placement import check_placement +from .runner import ValidatorCouldNotRun, run __all__ = [ "Finding", "GrammarUnavailable", + "ValidatorCouldNotRun", + "check_custom", + "check_hygiene", "check_placement", "classify_definitions", "get_parser", + "run", ] diff --git a/roboco/conventions/__main__.py b/roboco/conventions/__main__.py new file mode 100644 index 00000000..70bc475e --- /dev/null +++ b/roboco/conventions/__main__.py @@ -0,0 +1,73 @@ +"""``python -m roboco.conventions check --root --files ...``. + +Builds the effective map from the repo's ``.roboco/conventions.yml`` overlaid +on auto-derived defaults, runs the validator over the named files, and prints +one JSON finding per line. Exit 0 when it ran (findings may be empty or +present); exit 3 when it could not run (a broken config or a grammar failure), +with ``{"error": ...}`` on stderr — the fail-loud signal the gate blocks on. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from roboco.foundation.policy.conventions.effective_map import effective_map +from roboco.foundation.policy.conventions.models import ( + ConventionsParseError, + ConventionsStandard, +) + +from .runner import ValidatorCouldNotRun, run + +_CONVENTIONS_FILE = ".roboco/conventions.yml" +_EXIT_COULD_NOT_RUN = 3 + + +def _derive_stub(_root: Path) -> ConventionsStandard: + """Auto-derived defaults placeholder (wired to the repo scan in Task 5).""" + return ConventionsStandard() + + +def _load_file(root: Path) -> ConventionsStandard | None: + path = root / _CONVENTIONS_FILE + if not path.is_file(): + return None + return ConventionsStandard.parse_yaml(path.read_text()) + + +def _fail(reason: str) -> int: + print(json.dumps({"error": reason}), file=sys.stderr) + return _EXIT_COULD_NOT_RUN + + +def _run_check(root: Path, files: list[str]) -> int: + try: + file_standard = _load_file(root) + except ConventionsParseError as exc: + return _fail(f"unparseable {_CONVENTIONS_FILE}: {exc.reason}") + standard = effective_map(_derive_stub(root), file_standard) + try: + findings = run(root, files, standard) + except ValidatorCouldNotRun as exc: + return _fail(str(exc)) + for finding in findings: + print(finding.as_json()) + return 0 + + +def main(argv: list[str] | None = None) -> int: + """Parse args and run the requested command. Returns the process exit code.""" + parser = argparse.ArgumentParser(prog="python -m roboco.conventions") + subcommands = parser.add_subparsers(dest="command", required=True) + check = subcommands.add_parser("check", help="check changed files") + check.add_argument("--root", required=True, type=Path) + check.add_argument("--files", nargs="*", default=[]) + args = parser.parse_args(argv) + return _run_check(args.root, list(args.files)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/roboco/conventions/classify_ts.py b/roboco/conventions/classify_ts.py new file mode 100644 index 00000000..8bd50cc5 --- /dev/null +++ b/roboco/conventions/classify_ts.py @@ -0,0 +1,159 @@ +"""Classify each top-level TypeScript / TSX definition into a *kind*. + +Precision over recall: anything not clearly a model, component, or route +abstains to ``other``. Language must be ``typescript`` (``.ts``) or ``tsx`` +(``.tsx``) — JSX only parses under the tsx grammar. + +- ``model`` — a class with ``@Entity``/``@Schema``/``@Table`` (etc.), or a + ``z.*`` zod-schema const. +- ``route`` — a class with ``@Controller`` or an HTTP-method decorator. +- ``component`` — an exported function / arrow that contains JSX. +- ``other`` — anything ambiguous (abstain). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from roboco.foundation.policy.conventions.models import DefinitionKind + +from .grammars import get_parser + +if TYPE_CHECKING: + from tree_sitter import Node + +Definition = tuple[str, int, DefinitionKind] + +_MODEL_DECORATORS = frozenset({"Entity", "Schema", "Table", "ObjectType", "InputType"}) +_ROUTE_DECORATORS = frozenset( + {"Controller", "Get", "Post", "Put", "Delete", "Patch", "All"} +) +_CLASS_TYPES = frozenset({"class_declaration", "abstract_class_declaration"}) +_DECL_TYPES = frozenset({"lexical_declaration", "function_declaration"} | _CLASS_TYPES) +_JSX_TYPES = frozenset({"jsx_element", "jsx_self_closing_element", "jsx_fragment"}) + + +def classify_definitions(source: bytes, language: str = "tsx") -> list[Definition]: + """Return ``(name, line, kind)`` for each top-level definition in ``source``.""" + root = get_parser(language).parse(source).root_node + results: list[Definition] = [] + pending: list[str] = [] + for node in root.children: + if node.type == "decorator": + pending.append(_decorator_name(node)) + continue + results.extend(_classify_top_level(node, pending)) + pending = [] + return results + + +def _classify_top_level(node: Node, pending: list[str]) -> list[Definition]: + if node.type == "export_statement": + decl = _inner_declaration(node) + decorators = pending + _decorator_names(node) + return _classify_decl(decl, decorators) if decl is not None else [] + if node.type in _DECL_TYPES: + return _classify_decl(node, list(pending)) + return [] + + +def _classify_decl(decl: Node, decorators: list[str]) -> list[Definition]: + if decl.type in _CLASS_TYPES: + return _classify_class(decl, decorators) + if decl.type == "function_declaration": + return _classify_function(decl) + if decl.type == "lexical_declaration": + return _classify_lexical(decl) + return [] + + +def _classify_class(decl: Node, decorators: list[str]) -> list[Definition]: + name = _text(decl.child_by_field_name("name")) + if not name: + return [] + line = decl.start_point[0] + 1 + if any(d in _MODEL_DECORATORS for d in decorators): + return [(name, line, "model")] + if any(d in _ROUTE_DECORATORS for d in decorators): + return [(name, line, "route")] + return [(name, line, "other")] + + +def _classify_function(decl: Node) -> list[Definition]: + name = _text(decl.child_by_field_name("name")) + if not name: + return [] + kind: DefinitionKind = "component" if _contains_jsx(decl) else "other" + return [(name, decl.start_point[0] + 1, kind)] + + +def _classify_lexical(decl: Node) -> list[Definition]: + out: list[Definition] = [] + for declarator in decl.children: + if declarator.type != "variable_declarator": + continue + name = _text(declarator.child_by_field_name("name")) + value = declarator.child_by_field_name("value") + if not name or value is None: + continue + out.append((name, declarator.start_point[0] + 1, _lexical_kind(value))) + return out + + +def _lexical_kind(value: Node) -> DefinitionKind: + if _is_zod_schema(value): + return "model" + if value.type == "arrow_function" and _contains_jsx(value): + return "component" + return "other" + + +def _is_zod_schema(value: Node) -> bool: + node: Node | None = value + for _ in range(10): + if node is None: + return False + if node.type == "call_expression": + node = node.child_by_field_name("function") + elif node.type == "member_expression": + node = node.child_by_field_name("object") + else: + break + return node is not None and node.type == "identifier" and _text(node) == "z" + + +def _contains_jsx(node: Node) -> bool: + stack = list(node.children) + while stack: + current = stack.pop() + if current.type in _JSX_TYPES: + return True + stack.extend(current.children) + return False + + +def _decorator_names(node: Node) -> list[str]: + return [_decorator_name(c) for c in node.children if c.type == "decorator"] + + +def _decorator_name(decorator: Node) -> str: + expr = next((c for c in decorator.children if c.type != "@"), None) + if expr is not None and expr.type == "call_expression": + expr = expr.child_by_field_name("function") + if expr is None: + return "" + if expr.type == "identifier": + return _text(expr) + if expr.type == "member_expression": + return _text(expr.child_by_field_name("property")) + return "" + + +def _inner_declaration(export_node: Node) -> Node | None: + return next((c for c in export_node.children if c.type in _DECL_TYPES), None) + + +def _text(node: Node | None) -> str: + if node is None or node.text is None: + return "" + return node.text.decode() diff --git a/roboco/conventions/custom.py b/roboco/conventions/custom.py new file mode 100644 index 00000000..45af16c3 --- /dev/null +++ b/roboco/conventions/custom.py @@ -0,0 +1,51 @@ +"""Custom regex rules from the project's standard, scoped by language. + +A custom rule with an empty ``languages`` list applies to every language. A +malformed pattern abstains (it is skipped, not fatal) so a typo in the file +can never strand a task on the block gate. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from .findings import Finding + +if TYPE_CHECKING: + from roboco.foundation.policy.conventions.models import ( + ConventionsStandard, + CustomRule, + ) + + +def check_custom( + rel_path: str, source: bytes, language: str, standard: ConventionsStandard +) -> list[Finding]: + """Return findings for each custom rule that matches ``source``.""" + text = source.decode(errors="replace") + findings: list[Finding] = [] + for rule in standard.custom: + if rule.languages and language not in rule.languages: + continue + findings.extend(_matches(rel_path, text, rule)) + return findings + + +def _matches(rel_path: str, text: str, rule: CustomRule) -> list[Finding]: + try: + pattern = re.compile(rule.pattern) + except re.error: + return [] + return [ + Finding( + file=rel_path, + line=text.count("\n", 0, match.start()) + 1, + kind=None, + rule=rule.id, + level=rule.level, + message=rule.message, + fix_hint=f"matches custom rule '{rule.id}'", + ) + for match in pattern.finditer(text) + ] diff --git a/roboco/conventions/hygiene.py b/roboco/conventions/hygiene.py new file mode 100644 index 00000000..d50b5adf --- /dev/null +++ b/roboco/conventions/hygiene.py @@ -0,0 +1,105 @@ +"""Hygiene checks: inline comments and lint/type suppressions. + +Comment nodes come from the AST (so markers inside strings are never matched). +An *inline* comment trails code on its line; a full-line comment (indented or +not) is allowed. Suppression markers are language-scoped. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from roboco.foundation.policy.conventions.models import ( + BUILTIN_RULES, + ConventionsStandard, +) + +from .findings import Finding +from .grammars import get_parser + +if TYPE_CHECKING: + from collections.abc import Iterator + + from tree_sitter import Node + +_SUPPRESSIONS: dict[str, tuple[str, ...]] = { + "python": ("noqa", "type: ignore"), + "typescript": ("eslint-disable", "ts-ignore", "ts-expect-error"), + "tsx": ("eslint-disable", "ts-ignore", "ts-expect-error"), +} +_HYGIENE_TEXT: dict[str, tuple[str, str]] = { + "no_inline_comments": ( + "inline comment trailing code — keep narration out of the code", + "remove the trailing comment or lift it into a docstring", + ), + "no_lint_suppressions": ( + "lint/type suppression used — fix the root cause instead", + "remove the suppression and resolve the underlying error", + ), +} + + +def check_hygiene( + rel_path: str, source: bytes, language: str, standard: ConventionsStandard +) -> list[Finding]: + """Return inline-comment + suppression findings for ``source``.""" + root = get_parser(language).parse(source).root_node + lines = source.split(b"\n") + findings: list[Finding] = [] + for comment in _iter_comments(root): + findings.extend(_comment_findings(rel_path, comment, lines, language, standard)) + return findings + + +def _comment_findings( + rel_path: str, + comment: Node, + lines: list[bytes], + language: str, + standard: ConventionsStandard, +) -> list[Finding]: + row, col = comment.start_point + out: list[Finding] = [] + if _is_inline(lines, row, col): + out.append(_finding(rel_path, row + 1, "no_inline_comments", standard)) + text = comment.text.decode(errors="replace") if comment.text else "" + if any(marker in text for marker in _SUPPRESSIONS.get(language, ())): + out.append(_finding(rel_path, row + 1, "no_lint_suppressions", standard)) + return out + + +def _is_inline(lines: list[bytes], row: int, col: int) -> bool: + if row >= len(lines): + return False + return bool(lines[row][:col].strip()) + + +def _finding( + rel_path: str, line: int, rule: str, standard: ConventionsStandard +) -> Finding: + message, fix_hint = _HYGIENE_TEXT[rule] + return Finding( + file=rel_path, + line=line, + kind=None, + rule=rule, + level=_rule_level(standard, rule), + message=message, + fix_hint=fix_hint, + ) + + +def _rule_level(standard: ConventionsStandard, name: str) -> str: + rule = standard.rules.get(name) + if rule is not None: + return rule.level + return BUILTIN_RULES.get(name, "block") + + +def _iter_comments(root: Node) -> Iterator[Node]: + stack = [root] + while stack: + node = stack.pop() + if node.type == "comment": + yield node + stack.extend(node.children) diff --git a/roboco/conventions/runner.py b/roboco/conventions/runner.py new file mode 100644 index 00000000..4e7bb656 --- /dev/null +++ b/roboco/conventions/runner.py @@ -0,0 +1,77 @@ +"""Run all check families over a set of changed files, then filter waivers. + +Dispatch is by file extension; an unsupported extension or a missing file is +skipped. A grammar failure is *fail-loud*: the runner raises +``ValidatorCouldNotRun`` so the gate blocks rather than passing silently. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from . import classify_python, classify_ts +from .custom import check_custom +from .grammars import GrammarUnavailable +from .hygiene import check_hygiene +from .placement import check_placement + +if TYPE_CHECKING: + from roboco.foundation.policy.conventions.models import ConventionsStandard + + from .findings import Finding + from .placement import Definition + +_LANGUAGE_BY_SUFFIX = {".py": "python", ".ts": "typescript", ".tsx": "tsx"} + + +class ValidatorCouldNotRun(RuntimeError): + """Raised when the validator cannot analyze a file (fail-loud signal).""" + + def __init__(self, reason: str) -> None: + super().__init__(reason) + self.reason = reason + + +def run( + root: Path | str, files: list[str], standard: ConventionsStandard +) -> list[Finding]: + """Check ``files`` (repo-relative) under ``root`` against ``standard``.""" + root_path = Path(root) + findings: list[Finding] = [] + for rel in files: + language = _LANGUAGE_BY_SUFFIX.get(Path(rel).suffix) + if language is not None: + findings.extend(_check_file(root_path, rel, language, standard)) + return _apply_waivers(findings, standard) + + +def _check_file( + root: Path, rel: str, language: str, standard: ConventionsStandard +) -> list[Finding]: + try: + source = (root / rel).read_bytes() + except OSError: + return [] + try: + defs = _classify(language, source) + except GrammarUnavailable as exc: + raise ValidatorCouldNotRun(str(exc)) from exc + return ( + check_placement(rel, defs, standard) + + check_hygiene(rel, source, language, standard) + + check_custom(rel, source, language, standard) + ) + + +def _classify(language: str, source: bytes) -> list[Definition]: + if language == "python": + return classify_python.classify_definitions(source) + return classify_ts.classify_definitions(source, language) + + +def _apply_waivers( + findings: list[Finding], standard: ConventionsStandard +) -> list[Finding]: + waived = {(w.path, w.rule) for w in standard.waivers} + return [f for f in findings if (f.file, f.rule) not in waived] diff --git a/tests/unit/conventions/test_classify_ts.py b/tests/unit/conventions/test_classify_ts.py new file mode 100644 index 00000000..7fd1c3e7 --- /dev/null +++ b/tests/unit/conventions/test_classify_ts.py @@ -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 = () =>
;\n" + assert ("Btn", 1, "component") in classify_definitions(src, "tsx") + + +def test_function_component_is_component() -> None: + src = b"export function Card() { return ; }\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") == [] diff --git a/tests/unit/conventions/test_cli.py b/tests/unit/conventions/test_cli.py new file mode 100644 index 00000000..681f2114 --- /dev/null +++ b/tests/unit/conventions/test_cli.py @@ -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 diff --git a/tests/unit/conventions/test_custom.py b/tests/unit/conventions/test_custom.py new file mode 100644 index 00000000..f15973cc --- /dev/null +++ b/tests/unit/conventions/test_custom.py @@ -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) == [] diff --git a/tests/unit/conventions/test_hygiene.py b/tests/unit/conventions/test_hygiene.py new file mode 100644 index 00000000..ecec7386 --- /dev/null +++ b/tests/unit/conventions/test_hygiene.py @@ -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" diff --git a/tests/unit/conventions/test_runner.py b/tests/unit/conventions/test_runner.py new file mode 100644 index 00000000..e52cdcdc --- /dev/null +++ b/tests/unit/conventions/test_runner.py @@ -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 = () =>
;\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()) From dcb9bbd7ba51544410a2c7a6cbfbdff5a54c5b1b Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 03:53:21 +0200 Subject: [PATCH 04/29] feat(conventions): ROBOCO_CONVENTIONS_ENABLED flag + cache table + migration --- alembic/versions/043_conventions_cache.py | 56 +++++++++ roboco/config.py | 17 +++ roboco/db/tables.py | 39 +++++++ .../test_conventions_cache_table.py | 106 ++++++++++++++++++ tests/unit/config/test_conventions_flag.py | 17 +++ 5 files changed, 235 insertions(+) create mode 100644 alembic/versions/043_conventions_cache.py create mode 100644 tests/integration/test_conventions_cache_table.py create mode 100644 tests/unit/config/test_conventions_flag.py diff --git a/alembic/versions/043_conventions_cache.py b/alembic/versions/043_conventions_cache.py new file mode 100644 index 00000000..7583fbd6 --- /dev/null +++ b/alembic/versions/043_conventions_cache.py @@ -0,0 +1,56 @@ +"""Add the project_conventions_cache table. + +Caches the parsed *effective* architectural-conventions map per +``(project_id, commit_sha)`` so the map is re-derived only when HEAD moves. +``status`` records how the repo's ``.roboco/conventions.yml`` resolved at that +SHA (``ok`` | ``degraded`` | ``missing``). Pure schema change; no backfill. +Inert until ``ROBOCO_CONVENTIONS_ENABLED``. + +Revision ID: 043_conventions_cache +Revises: 042_worksession_toolchain +Create Date: 2026-06-22 + +NOTE: revision id is 21 chars — alembic's ``alembic_version.version_num`` is +``VARCHAR(32)`` and a longer id raises at record time. +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "043_conventions_cache" +down_revision = "042_worksession_toolchain" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "project_conventions_cache", + sa.Column("id", sa.UUID(as_uuid=True), nullable=False), + sa.Column("project_id", sa.UUID(as_uuid=True), nullable=False), + sa.Column("commit_sha", sa.String(length=40), nullable=False), + sa.Column("effective_map", postgresql.JSONB(), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("derived_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "project_id", "commit_sha", name="uq_project_conventions_cache_sha" + ), + ) + op.create_index( + "ix_project_conventions_cache_project_id", + "project_conventions_cache", + ["project_id"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_project_conventions_cache_project_id", + table_name="project_conventions_cache", + ) + op.drop_table("project_conventions_cache") diff --git a/roboco/config.py b/roboco/config.py index 11f3beda..c2d76516 100644 --- a/roboco/config.py +++ b/roboco/config.py @@ -191,6 +191,23 @@ def rag_store_url(self) -> str: ), ) + # ========================================================================== + # Architectural Conventions (per-project placement + house-style standard) + # ========================================================================== + # A repo-canonical .roboco/conventions.yml plus the roboco-conventions + # validator gate i_am_done / pr_pass on block-level placement and hygiene + # violations. Default-off; every hook (scaffold, ambient injection, baseline + # constraints, the gates) is inert when off. + conventions_enabled: bool = Field( + default=False, + description=( + "Master switch for the architectural-conventions standard: " + "auto-scaffold .roboco/conventions.yml, inject the architecture map, " + "attach baseline constraints, and block gates on violations. Off => " + "fully inert." + ), + ) + # ========================================================================== # Web Research (pluggable external search/fetch for Board + PM roles) # ========================================================================== diff --git a/roboco/db/tables.py b/roboco/db/tables.py index 329453b3..be1d209a 100644 --- a/roboco/db/tables.py +++ b/roboco/db/tables.py @@ -2249,3 +2249,42 @@ class TaskDraftTable(Base): Index("ix_task_drafts_session_id", "session_id"), Index("ix_task_drafts_task_id", "task_id"), ) + + +# ============================================================================= +# PROJECT CONVENTIONS CACHE TABLE +# ============================================================================= + + +class ProjectConventionsCacheTable(Base): + """Cached effective conventions map, keyed by (project, commit SHA). + + The effective map (auto-derived defaults overlaid by the committed + ``.roboco/conventions.yml``) is re-parsed only when HEAD moves; every + consumer reads this cache. ``status`` records how the file resolved at that + SHA: ``ok`` | ``degraded`` (unparseable, fell back) | ``missing``. + """ + + __tablename__ = "project_conventions_cache" + + id: Mapped[UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid4 + ) + project_id: Mapped[UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("projects.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + commit_sha: Mapped[str] = mapped_column(String(40), nullable=False) + effective_map: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) + status: Mapped[str] = mapped_column(String(20), nullable=False) + derived_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(UTC), nullable=False + ) + + __table_args__ = ( + UniqueConstraint( + "project_id", "commit_sha", name="uq_project_conventions_cache_sha" + ), + ) diff --git a/tests/integration/test_conventions_cache_table.py b/tests/integration/test_conventions_cache_table.py new file mode 100644 index 00000000..50f52512 --- /dev/null +++ b/tests/integration/test_conventions_cache_table.py @@ -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() diff --git a/tests/unit/config/test_conventions_flag.py b/tests/unit/config/test_conventions_flag.py new file mode 100644 index 00000000..0698ca7d --- /dev/null +++ b/tests/unit/config/test_conventions_flag.py @@ -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 From 0b7b9fd9c9c23f443e097ed7d996fb0bf47120b9 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 03:58:15 +0200 Subject: [PATCH 05/29] feat(conventions): repo auto-scan + scaffold draft renderer --- roboco/conventions/__main__.py | 8 +- roboco/conventions/scan.py | 213 ++++++++++++++++++++++++++++ tests/unit/conventions/test_scan.py | 68 +++++++++ 3 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 roboco/conventions/scan.py create mode 100644 tests/unit/conventions/test_scan.py diff --git a/roboco/conventions/__main__.py b/roboco/conventions/__main__.py index 70bc475e..9e9a3f02 100644 --- a/roboco/conventions/__main__.py +++ b/roboco/conventions/__main__.py @@ -21,16 +21,12 @@ ) from .runner import ValidatorCouldNotRun, run +from .scan import derive_from_scan _CONVENTIONS_FILE = ".roboco/conventions.yml" _EXIT_COULD_NOT_RUN = 3 -def _derive_stub(_root: Path) -> ConventionsStandard: - """Auto-derived defaults placeholder (wired to the repo scan in Task 5).""" - return ConventionsStandard() - - def _load_file(root: Path) -> ConventionsStandard | None: path = root / _CONVENTIONS_FILE if not path.is_file(): @@ -48,7 +44,7 @@ def _run_check(root: Path, files: list[str]) -> int: file_standard = _load_file(root) except ConventionsParseError as exc: return _fail(f"unparseable {_CONVENTIONS_FILE}: {exc.reason}") - standard = effective_map(_derive_stub(root), file_standard) + standard = effective_map(derive_from_scan(root), file_standard) try: findings = run(root, files, standard) except ValidatorCouldNotRun as exc: diff --git a/roboco/conventions/scan.py b/roboco/conventions/scan.py new file mode 100644 index 00000000..3a02aef5 --- /dev/null +++ b/roboco/conventions/scan.py @@ -0,0 +1,213 @@ +"""Auto-derive a conventions standard from a repo, and render it to YAML. + +Pure (filesystem read only). ``derive_from_scan`` infers module boundaries +from directory names, detects languages from file extensions, seeds the org +``BUILTIN_RULES``, and best-effort lifts imperative ``CLAUDE.md`` lines that +name a concrete token into warn-level custom rules. ``render_yaml`` emits a +commented, human-friendly file that round-trips through ``parse_yaml``. + +Lives in the validator package (not ``services/``) so the lightweight +``roboco.conventions`` CLI can import it without pulling DB-backed services. +""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +import yaml + +from roboco.foundation.policy.conventions.models import ( + BUILTIN_RULES, + ConventionsStandard, + CustomRule, + DefinitionKind, + Module, + Rule, +) + +_IGNORE_DIRS = frozenset( + { + "node_modules", + "venv", + "__pycache__", + "dist", + "build", + "site-packages", + "target", + "vendor", + } +) + +# Directory-name keywords -> (purpose, forbidden definition kinds). Only kinds +# the classifiers actually emit can ever fire, so extra entries are harmless. +_MODULE_PATTERNS: tuple[tuple[frozenset[str], str, tuple[DefinitionKind, ...]], ...] = ( + ( + frozenset({"routers", "routes", "api", "endpoints", "controllers"}), + "HTTP routes / endpoint definitions", + ("model", "helper"), + ), + ( + frozenset({"models", "schemas", "entities", "dto", "dtos"}), + "data models / schemas", + ("route",), + ), + ( + frozenset({"services", "domain", "usecases"}), + "business logic & orchestration", + ("route",), + ), + ( + frozenset({"components"}), + "UI components", + ("model", "route"), + ), + ( + frozenset({"helpers", "utils", "util", "lib"}), + "shared helpers / utilities", + ("route", "component"), + ), +) + +_LANGUAGE_BY_SUFFIX = {".py": "python", ".ts": "typescript", ".tsx": "typescript"} +_IMPERATIVE = re.compile(r"\b(never|don't|do not|avoid|no)\b", re.IGNORECASE) +_CODE_SPAN = re.compile(r"`([^`]+)`") +_MAX_LIFTED_RULES = 25 + + +def derive_from_scan(root: Path | str) -> ConventionsStandard: + """Infer a conventions standard from the repository at ``root``.""" + root_path = Path(root) + return ConventionsStandard( + languages=_detect_languages(root_path), + modules=_scan_modules(root_path), + rules=_seed_rules(), + custom=_lift_claude_md(root_path), + ) + + +def _seed_rules() -> dict[str, Rule]: + return {name: Rule(name=name, level=level) for name, level in BUILTIN_RULES.items()} + + +def _walk_dirs(root: Path) -> list[tuple[str, list[str], list[str]]]: + walked: list[tuple[str, list[str], list[str]]] = [] + for dirpath, dirnames, files in os.walk(root): + dirnames[:] = sorted( + d for d in dirnames if d not in _IGNORE_DIRS and not d.startswith(".") + ) + walked.append((dirpath, dirnames, sorted(files))) + return walked + + +def _scan_modules(root: Path) -> list[Module]: + modules: list[Module] = [] + for dirpath, dirnames, _files in _walk_dirs(root): + for name in dirnames: + spec = _match_module(name) + if spec is None: + continue + rel = (Path(dirpath) / name).relative_to(root).as_posix() + purpose, forbidden = spec + modules.append(Module(path=rel, purpose=purpose, forbidden=list(forbidden))) + return modules + + +def _match_module(name: str) -> tuple[str, tuple[DefinitionKind, ...]] | None: + lname = name.lower() + for keywords, purpose, forbidden in _MODULE_PATTERNS: + if lname in keywords: + return purpose, forbidden + return None + + +def _detect_languages(root: Path) -> list[str]: + languages: list[str] = [] + for _dirpath, _dirnames, files in _walk_dirs(root): + for filename in files: + language = _LANGUAGE_BY_SUFFIX.get(Path(filename).suffix) + if language is not None and language not in languages: + languages.append(language) + return languages + + +def _lift_claude_md(root: Path) -> list[CustomRule]: + path = root / "CLAUDE.md" + if not path.is_file(): + return [] + rules: list[CustomRule] = [] + seen: set[str] = set() + for line in path.read_text(errors="replace").splitlines(): + rule = _rule_from_line(line, seen) + if rule is not None: + rules.append(rule) + if len(rules) >= _MAX_LIFTED_RULES: + break + return rules + + +def _rule_from_line(line: str, seen: set[str]) -> CustomRule | None: + if not _IMPERATIVE.search(line): + return None + span = _CODE_SPAN.search(line) + if span is None: + return None + token = span.group(1).strip() + rule_id = _slug(token) + if not token or not rule_id or rule_id in seen: + return None + seen.add(rule_id) + return CustomRule( + id=rule_id, + pattern=re.escape(token), + message=line.strip().lstrip("-*# ").strip(), + level="warn", + ) + + +def _slug(token: str) -> str: + return re.sub(r"[^a-z0-9]+", "-", token.lower()).strip("-") + + +def _to_yaml_data(standard: ConventionsStandard) -> dict[str, object]: + return { + "version": standard.version, + "languages": list(standard.languages), + "modules": [ + {"path": m.path, "purpose": m.purpose, "forbidden": list(m.forbidden)} + for m in standard.modules + ], + "rules": {name: {"level": r.level} for name, r in standard.rules.items()}, + "custom": [ + { + "id": c.id, + "pattern": c.pattern, + "message": c.message, + "level": c.level, + "languages": list(c.languages), + } + for c in standard.custom + ], + "waivers": [ + {"path": w.path, "rule": w.rule, "reason": w.reason} + for w in standard.waivers + ], + } + + +def render_yaml(standard: ConventionsStandard) -> str: + """Render ``standard`` to a commented ``.roboco/conventions.yml`` string.""" + header = ( + "# Architectural conventions for this project.\n" + "# Auto-scaffolded by RoboCo — edit freely; this file is canonical.\n" + "# Each module lists definition KINDS forbidden in it; rules toggle\n" + "# warn/block; waivers are accountable, PR-reviewed escape hatches.\n" + ) + body = yaml.safe_dump( + _to_yaml_data(standard), + sort_keys=False, + default_flow_style=False, + allow_unicode=True, + ) + return header + body diff --git a/tests/unit/conventions/test_scan.py b/tests/unit/conventions/test_scan.py new file mode 100644 index 00000000..d8f6c4b7 --- /dev/null +++ b/tests/unit/conventions/test_scan.py @@ -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 From 8fe0925740121979b9f39e306d9fb34b954bc650 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 04:13:07 +0200 Subject: [PATCH 06/29] feat(conventions): ConventionsService (cache/baseline/ambient/scaffold/restore) --- roboco/services/conventions.py | 262 ++++++++++++++++++ roboco/services/git.py | 89 ++++++ tests/integration/test_conventions_service.py | 183 ++++++++++++ tests/integration/test_git_conventions_pr.py | 104 +++++++ 4 files changed, 638 insertions(+) create mode 100644 roboco/services/conventions.py create mode 100644 tests/integration/test_conventions_service.py create mode 100644 tests/integration/test_git_conventions_pr.py diff --git a/roboco/services/conventions.py b/roboco/services/conventions.py new file mode 100644 index 00000000..099535e8 --- /dev/null +++ b/roboco/services/conventions.py @@ -0,0 +1,262 @@ +"""ConventionsService — the per-project architectural-conventions standard. + +Builds the *effective* conventions map (auto-derived defaults overlaid by the +committed ``.roboco/conventions.yml``), caches it per ``(project, HEAD sha)``, +and renders it for the two carriers (per-task baseline constraints + the +ambient prompt block). Also scaffolds / restores the committed file via a PR. + +Resilience: a missing file degrades to auto-derived defaults; an unparseable +file falls back to the last-good cached map (never silently off). DB writes + +git side effects live here (service layer); the schema + classifiers it builds +on are pure. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import select + +from roboco.conventions.scan import derive_from_scan, render_yaml +from roboco.db.tables import ProjectConventionsCacheTable +from roboco.foundation.policy.conventions.effective_map import effective_map +from roboco.foundation.policy.conventions.models import ( + ConventionsParseError, + ConventionsStandard, +) +from roboco.services.base import BaseService +from roboco.services.git import get_git_service + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from roboco.db.tables import ProjectTable + +_SCAFFOLD_BRANCH = "chore/roboco-conventions-scaffold" +_AMBIENT_CHAR_CAP = 1200 + + +@dataclass(frozen=True) +class ScaffoldResult: + """Outcome of a scaffold / restore: the branch + PR (if one was opened).""" + + pr_number: int | None + branch: str + created: bool + + +@dataclass(frozen=True) +class ConventionsHealth: + """Health of a project's standard: current status + last-good SHA.""" + + status: str + head_sha: str + last_ok_sha: str | None + + +class ConventionsService(BaseService): + """Cache, render, scaffold, and restore a project's conventions standard.""" + + async def get_map(self, project: ProjectTable) -> ConventionsStandard: + """Return the effective standard for ``project`` at its current HEAD.""" + pid = self._pid(project) + head = self._head_sha(project) + cached = await self._cache_get(pid, head) + if cached is not None: + return ConventionsStandard.model_validate(cached.effective_map) + + file_standard, status = self._read_committed_standard(project) + if status == "degraded": + last_good = await self._latest_ok_map(pid) + if last_good is not None: + await self._cache_put(pid, head, last_good, status) + return last_good + + mapping = effective_map(self._derive(project), file_standard) + await self._cache_put(pid, head, mapping, status) + return mapping + + async def baseline_constraints(self, project: ProjectTable) -> list[str]: + """Render the project's block rules + module boundaries as constraints.""" + mapping = await self.get_map(project) + constraints = [ + f"Convention (block): {name.replace('_', ' ')}" + for name, rule in mapping.rules.items() + if rule.level == "block" + ] + constraints += [ + f"Place code per the map — {m.path} is for {m.purpose} " + f"(no {', '.join(m.forbidden)} here)" + for m in mapping.modules + if m.forbidden + ] + return constraints + + async def render_ambient_block(self, project: ProjectTable) -> str: + """Render a compact, bounded 'Architectural Standard' prompt block.""" + mapping = await self.get_map(project) + lines = [ + "## Architectural Standard", + "Place each definition in the module that owns its kind:", + ] + for module in mapping.modules: + suffix = ( + f" — forbidden: {', '.join(module.forbidden)}" + if module.forbidden + else "" + ) + lines.append(f"- `{module.path}`: {module.purpose}{suffix}") + block = sorted(n for n, r in mapping.rules.items() if r.level == "block") + if block: + lines.append("Block-level rules: " + ", ".join(block) + ".") + text = "\n".join(lines) + if len(text) > _AMBIENT_CHAR_CAP: + return text[: _AMBIENT_CHAR_CAP - 1].rstrip() + "…" + return text + + async def scaffold(self, project: ProjectTable) -> ScaffoldResult: + """Open a PR adding the auto-scaffolded ``.roboco/conventions.yml``.""" + mapping = await self.get_map(project) + return await self._publish(project, render_yaml(mapping), restore=False) + + async def restore(self, project: ProjectTable) -> ScaffoldResult: + """Open a PR re-committing the file from the last-good map (or a scan).""" + last_good = await self._latest_ok_map(self._pid(project)) + mapping = last_good if last_good is not None else self._derive(project) + return await self._publish(project, render_yaml(mapping), restore=True) + + async def health(self, project: ProjectTable) -> ConventionsHealth: + """Report the standard's status at HEAD + the last-good commit SHA.""" + pid = self._pid(project) + head = self._head_sha(project) + current = await self._cache_get(pid, head) + last_ok = await self._latest_ok_row(pid) + return ConventionsHealth( + status=current.status if current is not None else "unknown", + head_sha=head, + last_ok_sha=last_ok.commit_sha if last_ok is not None else None, + ) + + # -- internals ---------------------------------------------------------- # + + @staticmethod + def _pid(project: ProjectTable) -> UUID: + # ProjectTable.id is typed as the SQLAlchemy UUID column; normalize to a + # plain uuid.UUID for the cache-row helpers. + return UUID(str(project.id)) + + @staticmethod + def _head_sha(project: ProjectTable) -> str: + return project.head_commit or "HEAD" + + @staticmethod + def _workspace_root(project: ProjectTable) -> Path | None: + if not project.workspace_path: + return None + path = Path(project.workspace_path) + return path if path.exists() else None + + def _derive(self, project: ProjectTable) -> ConventionsStandard: + root = self._workspace_root(project) + return derive_from_scan(root) if root is not None else ConventionsStandard() + + def _read_committed_standard( + self, project: ProjectTable + ) -> tuple[ConventionsStandard | None, str]: + root = self._workspace_root(project) + if root is None: + return None, "missing" + path = root / ".roboco" / "conventions.yml" + if not path.is_file(): + return None, "missing" + try: + text = path.read_text() + except OSError: + return None, "missing" + try: + return ConventionsStandard.parse_yaml(text), "ok" + except ConventionsParseError: + return None, "degraded" + + async def _publish( + self, project: ProjectTable, content: str, *, restore: bool + ) -> ScaffoldResult: + action = "restore" if restore else "scaffold" + title = f"chore(conventions): {action} .roboco/conventions.yml" + body = ( + "Auto-generated by RoboCo's architectural-conventions standard. " + "This file is repo-canonical — review, edit, or close as you like." + ) + git = get_git_service(self.session) + result = await git.open_conventions_pr( + project.slug, + content=content, + branch=_SCAFFOLD_BRANCH, + title=title, + body=body, + ) + if result is None: + return ScaffoldResult( + pr_number=None, branch=_SCAFFOLD_BRANCH, created=False + ) + return ScaffoldResult( + pr_number=result.get("pr_number"), + branch=result.get("branch", _SCAFFOLD_BRANCH), + created=True, + ) + + async def _cache_get( + self, project_id: UUID, commit_sha: str + ) -> ProjectConventionsCacheTable | None: + result = await self.session.execute( + select(ProjectConventionsCacheTable).where( + ProjectConventionsCacheTable.project_id == project_id, + ProjectConventionsCacheTable.commit_sha == commit_sha, + ) + ) + return result.scalar_one_or_none() + + async def _cache_put( + self, + project_id: UUID, + commit_sha: str, + mapping: ConventionsStandard, + status: str, + ) -> None: + self.session.add( + ProjectConventionsCacheTable( + project_id=project_id, + commit_sha=commit_sha, + effective_map=mapping.model_dump(mode="json"), + status=status, + ) + ) + await self.session.flush() + + async def _latest_ok_row( + self, project_id: UUID + ) -> ProjectConventionsCacheTable | None: + result = await self.session.execute( + select(ProjectConventionsCacheTable) + .where( + ProjectConventionsCacheTable.project_id == project_id, + ProjectConventionsCacheTable.status == "ok", + ) + .order_by(ProjectConventionsCacheTable.derived_at.desc()) + .limit(1) + ) + return result.scalars().first() + + async def _latest_ok_map(self, project_id: UUID) -> ConventionsStandard | None: + row = await self._latest_ok_row(project_id) + if row is None: + return None + return ConventionsStandard.model_validate(row.effective_map) + + +def get_conventions_service(session: AsyncSession) -> ConventionsService: + """Construct a ConventionsService bound to ``session``.""" + return ConventionsService(session) diff --git a/roboco/services/git.py b/roboco/services/git.py index a7e68d64..2c2f57cf 100644 --- a/roboco/services/git.py +++ b/roboco/services/git.py @@ -3720,6 +3720,95 @@ async def commit( "deletions": deletions, } + async def open_conventions_pr( + self, + project_slug: str, + *, + content: str, + branch: str, + title: str, + body: str, + ) -> dict[str, Any] | None: + """Commit ``.roboco/conventions.yml`` on ``branch`` and open a PR. + + Best-effort and project-level (no task): writes ``content`` to the + project workspace on a fresh ``branch`` cut from the default branch and + commits it (always), then pushes + opens a PR (only when the project has + a git token + remote). Returns ``{"branch", "pr_number", "pr_url"}`` with + ``pr_number=None`` when the remote PR could not be opened, or ``None`` + when the project has no usable workspace to scaffold into. + """ + project_service = get_project_service(self.session) + project = await project_service.get_by_slug(project_slug) + if project is None or not project.workspace_path: + return None + workspace = Path(project.workspace_path) + if not workspace.exists(): + return None + base = project.default_branch or "master" + spec = _ConventionsPr(content=content, branch=branch, title=title, body=body) + await self._commit_conventions_file(workspace, base, spec) + return await self._push_and_open_conventions_pr( + project_slug, workspace, base, spec + ) + + async def _commit_conventions_file( + self, workspace: Path, base: str, spec: _ConventionsPr + ) -> None: + await self._run_git(workspace, ["checkout", base], check=False) + await self._run_git(workspace, ["checkout", "-B", spec.branch]) + target = workspace / ".roboco" / "conventions.yml" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(spec.content) + await self._run_git(workspace, ["add", ".roboco/conventions.yml"]) + await self._run_git(workspace, ["commit", "-m", spec.title]) + + async def _push_and_open_conventions_pr( + self, project_slug: str, workspace: Path, base: str, spec: _ConventionsPr + ) -> dict[str, Any]: + unopened: dict[str, Any] = { + "branch": spec.branch, + "pr_number": None, + "pr_url": None, + } + token = await self._token_for_project(project_slug) + if not token: + return unopened + try: + await self.push(workspace) + owner, repo = self._parse_github_remote(workspace) + resp = await self._post_pr( + owner, + repo, + token, + { + "title": spec.title, + "head": spec.branch, + "base": base, + "body": spec.body, + }, + ) + except GitError: + return unopened + if not resp.is_success: + return unopened + data = resp.json() + return { + "branch": spec.branch, + "pr_number": data.get("number"), + "pr_url": data.get("html_url"), + } + + +@dataclass(frozen=True) +class _ConventionsPr: + """The fields for a project-level conventions scaffold/restore PR.""" + + content: str + branch: str + title: str + body: str + def get_git_service(session: AsyncSession) -> GitService: """Factory function to get git service.""" diff --git a/tests/integration/test_conventions_service.py b/tests/integration/test_conventions_service.py new file mode 100644 index 00000000..cd23e589 --- /dev/null +++ b/tests/integration/test_conventions_service.py @@ -0,0 +1,183 @@ +"""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, branch: str, **_kwargs: str + ) -> dict[str, Any]: + self.calls.append({"slug": project_slug, "content": content, "branch": branch}) + return {"branch": branch, "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"] diff --git a/tests/integration/test_git_conventions_pr.py b/tests/integration/test_git_conventions_pr.py new file mode 100644 index 00000000..99aa46f4 --- /dev/null +++ b/tests/integration/test_git_conventions_pr.py @@ -0,0 +1,104 @@ +"""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", + branch=_SCAFFOLD_BRANCH, + 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", + branch=_SCAFFOLD_BRANCH, + title="t", + body="b", + ) + assert result is None From 811b91ba25c61bd0a63b44403c0fca3167299c36 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 04:15:46 +0200 Subject: [PATCH 07/29] feat(conventions): auto-scaffold on project registration (flag-gated) --- roboco/services/project.py | 20 ++++ .../test_project_scaffold_conventions.py | 97 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 tests/integration/test_project_scaffold_conventions.py diff --git a/roboco/services/project.py b/roboco/services/project.py index 2cd7d4a4..8e1ce940 100644 --- a/roboco/services/project.py +++ b/roboco/services/project.py @@ -120,8 +120,28 @@ async def create( if isinstance(data.assigned_cell, Team) else data.assigned_cell, ) + await self._maybe_scaffold_conventions(project) return project + async def _maybe_scaffold_conventions(self, project: ProjectTable) -> None: + """Best-effort: open the conventions scaffold PR (flag-gated, never fatal). + + Imported lazily to avoid the project -> conventions -> git -> project + import cycle. A scaffold hiccup must never fail project registration. + """ + if not settings.conventions_enabled: + return + from roboco.services.conventions import get_conventions_service + + try: + await get_conventions_service(self.session).scaffold(project) + except Exception as exc: + self.log.warning( + "Conventions scaffold failed (non-fatal)", + project_id=str(project.id), + error=str(exc), + ) + async def get(self, project_id: UUID) -> ProjectTable | None: """Get a project by ID.""" result = await self.session.execute( diff --git a/tests/integration/test_project_scaffold_conventions.py b/tests/integration/test_project_scaffold_conventions.py new file mode 100644 index 00000000..8ee0620a --- /dev/null +++ b/tests/integration/test_project_scaffold_conventions.py @@ -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 From df6b9cc15a3a7594adf3b8de20b745d570ae370b Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 04:20:36 +0200 Subject: [PATCH 08/29] feat(conventions): TaskDescription.constraints + auto-baseline attach --- roboco/foundation/policy/content/models.py | 17 ++++ roboco/services/task.py | 45 +++++++++ .../test_task_baseline_constraints.py | 93 +++++++++++++++++++ .../test_task_description_constraints.py | 50 ++++++++++ 4 files changed, 205 insertions(+) create mode 100644 tests/integration/test_task_baseline_constraints.py create mode 100644 tests/unit/foundation/policy/content/test_task_description_constraints.py diff --git a/roboco/foundation/policy/content/models.py b/roboco/foundation/policy/content/models.py index 50a33fc6..ffdf1a8e 100644 --- a/roboco/foundation/policy/content/models.py +++ b/roboco/foundation/policy/content/models.py @@ -185,12 +185,14 @@ class TaskDescription(_Content): what_this_builds: list[str] = Field(default_factory=list) the_work: list[WorkUnit] = Field(default_factory=list) notes: list[str] = Field(default_factory=list) + constraints: list[str] = Field(default_factory=list) acceptance_criteria: list[str] = Field(default_factory=list) @field_validator( "what_this_builds", "the_work", "notes", + "constraints", "acceptance_criteria", mode="before", ) @@ -198,6 +200,19 @@ class TaskDescription(_Content): def _coerce(cls, v: Any) -> Any: return coerce_to_list(v) + def with_baseline_constraints(self, baseline: list[str]) -> TaskDescription: + """Return a copy with ``baseline`` prepended to ``constraints``. + + Idempotent and order-stable: baseline first, then this task's own + constraints that aren't already present (deduped by exact string), so + a second attach of the same baseline is a no-op. + """ + merged = list(baseline) + for constraint in self.constraints: + if constraint not in merged: + merged.append(constraint) + return self.model_copy(update={"constraints": merged}) + @field_validator("objective") @classmethod def _nontrivial_objective(cls, v: str) -> str: @@ -230,6 +245,8 @@ def render_markdown(self) -> str: parts.append("## The Work\n" + "\n\n".join(units)) if self.notes: parts.append(_section("Notes", _bullets(self.notes))) + if self.constraints: + parts.append(_section("Constraints", _bullets(self.constraints))) parts.append( _section("Acceptance Criteria", _bullets(self.acceptance_criteria)) ) diff --git a/roboco/services/task.py b/roboco/services/task.py index 1b2804ff..39983ad2 100644 --- a/roboco/services/task.py +++ b/roboco/services/task.py @@ -659,8 +659,53 @@ async def create(self, req: TaskCreateRequest) -> TaskTable: title=req.title, team=req.team if isinstance(req.team, str) else req.team.value, ) + await self._attach_baseline_constraints(task) return task + async def _attach_baseline_constraints(self, task: TaskTable) -> None: + """Append the project's block-rule constraints to the task description. + + Server-derived backstop (flag-gated): every project task carries the + hard conventions even if nothing upstream added them. Idempotent — the + rendered ``## Constraints`` section is appended at most once. Best-effort: + a failure never blocks task creation. Imported lazily to avoid the + task -> conventions -> git import chain. + """ + from roboco.config import settings + + if not settings.conventions_enabled or task.project_id is None: + return + if task.description and "## Constraints" in task.description: + return + + from roboco.services.conventions import get_conventions_service + from roboco.services.project import get_project_service + + project = await get_project_service(self.session).get( + UUID(str(task.project_id)) + ) + if project is None: + return + try: + baseline = await get_conventions_service( + self.session + ).baseline_constraints(project) + except Exception as exc: + self.log.warning( + "Baseline-constraints attach failed (non-fatal)", + task_id=str(task.id), + error=str(exc), + ) + return + if not baseline: + return + + section = "## Constraints\n" + "\n".join(f"- {item}" for item in baseline) + task.description = ( + f"{task.description}\n\n{section}" if task.description else section + ) + await self.session.flush() + async def external_review_task_exists( self, project_id: UUID, pr_number: int, head_sha: str | None = None ) -> bool: diff --git a/tests/integration/test_task_baseline_constraints.py b/tests/integration/test_task_baseline_constraints.py new file mode 100644 index 00000000..2616df0b --- /dev/null +++ b/tests/integration/test_task_baseline_constraints.py @@ -0,0 +1,93 @@ +"""TaskService.create appends the project's baseline constraints (flag-gated).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import 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=agent.id, + task_type=TaskType.CODE, + nature=TaskNature.TECHNICAL, + estimated_complexity=Complexity.MEDIUM, + project_id=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_attach_is_idempotent_when_section_present( + db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(settings, "conventions_enabled", True) + agent, project = await _seed(db_session) + seeded = "Do the work\n\n## Constraints\n- already here" + task = await TaskService(db_session).create(_req(agent, project, seeded)) + assert task.description is not None + assert task.description.count("## Constraints") == 1 diff --git a/tests/unit/foundation/policy/content/test_task_description_constraints.py b/tests/unit/foundation/policy/content/test_task_description_constraints.py new file mode 100644 index 00000000..7042ec35 --- /dev/null +++ b/tests/unit/foundation/policy/content/test_task_description_constraints.py @@ -0,0 +1,50 @@ +"""TaskDescription.constraints: rendering + idempotent baseline merge.""" + +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() + + +def test_with_baseline_prepends_and_dedupes() -> None: + merged = _desc(constraints=["task-specific x"]).with_baseline_constraints( + ["block a", "block b"] + ) + assert merged.constraints == ["block a", "block b", "task-specific x"] + + +def test_with_baseline_drops_duplicate_of_existing() -> None: + merged = _desc(constraints=["block a"]).with_baseline_constraints( + ["block a", "block b"] + ) + assert merged.constraints == ["block a", "block b"] + + +def test_with_baseline_is_idempotent() -> None: + once = _desc(constraints=["block a"]).with_baseline_constraints( + ["block a", "block b"] + ) + twice = once.with_baseline_constraints(["block a", "block b"]) + assert once.constraints == twice.constraints == ["block a", "block b"] From 0bd63574351b11cd7952295302b22ecc402c5677 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 04:25:47 +0200 Subject: [PATCH 09/29] feat(conventions): ambient architecture-map injection at spawn --- pyproject.toml | 4 ++ roboco/agents/factories/_base.py | 24 +++++++ roboco/runtime/orchestrator.py | 43 ++++++++++-- .../test_conventions_ambient_resolver.py | 69 +++++++++++++++++++ .../test_conventions_ambient_injection.py | 25 +++++++ 5 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 tests/integration/test_conventions_ambient_resolver.py create mode 100644 tests/unit/agents/test_conventions_ambient_injection.py diff --git a/pyproject.toml b/pyproject.toml index 5fe39734..4e46c9be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -178,6 +178,10 @@ select = [ # the package imports without tree-sitter present and a missing grammar fails # loud at call time (GrammarUnavailable) instead of crashing the import. "roboco/conventions/grammars.py" = ["PLC0415"] +# The conventions ambient-layer resolver defers its config + ConventionsService +# imports so the prompt-composition utility stays decoupled from the heavy +# service graph (it loads at every agent spawn). +"roboco/agents/factories/_base.py" = ["PLC0415"] # Test fixtures that reload modules to test env-var-at-import-time behavior. # ARG002: ApiClient-subclassing fakes must keep the superclass parameter names # for mypy's override check, so unused override-stub args can't be renamed. diff --git a/roboco/agents/factories/_base.py b/roboco/agents/factories/_base.py index bbdaf377..ea2a8bb4 100644 --- a/roboco/agents/factories/_base.py +++ b/roboco/agents/factories/_base.py @@ -8,6 +8,9 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + + from roboco.db.tables import ProjectTable from roboco.models import AgentRole, Team @@ -202,6 +205,7 @@ def compose_prompt( team: "Team | None", agent_slug: str, base_path: Path | None = None, + ambient: str | None = None, ) -> str: """ Compose a system prompt from layered components. @@ -223,6 +227,8 @@ def compose_prompt( team: Agent's team (backend, frontend, ux_ui, or None for board) agent_slug: Agent's slug identifier (e.g., "be-dev-1") base_path: Optional override for prompts base path + ambient: Optional ambient layer (e.g. the project's architectural + standard) appended last; omitted when None/empty Returns: Composed system prompt string @@ -238,6 +244,7 @@ def compose_prompt( _autogen_verbs_layer(prompts_path, role), _team_layer(prompts_path, team), _load_layer(prompts_path / "identities" / f"{agent_slug}.md"), + ambient, ): if layer: parts.append(layer) @@ -246,6 +253,23 @@ def compose_prompt( return "\n\n---\n\n".join(parts) +async def conventions_ambient_layer( + session: "AsyncSession", project: "ProjectTable | None" +) -> str | None: + """Render the project's architectural-standard prompt block (flag-gated). + + Returns None when the conventions subsystem is off or no project is in + scope, so a flag-off / board-level spawn injects nothing. + """ + from roboco.config import settings + + if not settings.conventions_enabled or project is None: + return None + from roboco.services.conventions import get_conventions_service + + return await get_conventions_service(session).render_ambient_block(project) + + def make_slug(name: str) -> str: """Convert a name to a URL-safe slug.""" return name.lower().replace(" ", "-") diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index bd8ad378..fc7770e5 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -1636,7 +1636,9 @@ async def _prepare_agent_spawn( git_context: SpawnGitContext | None, ) -> tuple[AgentConfig, AgentInstance, Path | None]: """Build AgentConfig + AgentInstance and surface per-agent settings path.""" - blueprint_path = self._generate_composed_prompt(agent_id) + project_slug = self._resolve_project_slug(git_context, agent_id, task_id) + ambient = await self._resolve_conventions_ambient(project_slug) + blueprint_path = self._generate_composed_prompt(agent_id, ambient=ambient) canonical_role = get_agent_role(agent_id) team = get_agent_team(agent_id) or "backend" @@ -1650,7 +1652,6 @@ async def _prepare_agent_spawn( if not model: model = route.model_name - project_slug = self._resolve_project_slug(git_context, agent_id, task_id) workspace_path = _agent_workspace_path(project_slug, team, agent_id) cell_workspace_path = _cell_workspace_path(project_slug, team) @@ -2436,11 +2437,15 @@ async def _generate_mcp_config( return config_path - def _generate_composed_prompt(self, agent_id: str) -> Path: + def _generate_composed_prompt( + self, agent_id: str, ambient: str | None = None + ) -> Path: """Generate composed system prompt for an agent. Uses the layered prompt composition system: base.md + roles/{role}.md + teams/{team}.md + identities/{agent}.md + plus an optional ``ambient`` layer (the project's architectural + standard, resolved by the async spawn path). Returns: Path to the generated prompt file @@ -2457,7 +2462,7 @@ def _generate_composed_prompt(self, agent_id: str) -> Path: raise ValueError(f"Unknown role for agent: {agent_id}") # Compose the prompt from layers - prompt_content = compose_prompt(role_enum, team_enum, agent_id) + prompt_content = compose_prompt(role_enum, team_enum, agent_id, ambient=ambient) # Determine output directory if PROJECT_HOST_PATH: @@ -2484,6 +2489,36 @@ def _generate_composed_prompt(self, agent_id: str) -> Path: return prompt_path + async def _resolve_conventions_ambient( + self, project_slug: str | None + ) -> str | None: + """Resolve the architectural-standard ambient block for the spawn. + + Best-effort + flag-gated: returns None (no ambient layer) when the + subsystem is off, no project is in scope, or anything fails — a prompt + compose must never be blocked by conventions resolution. + """ + from roboco.config import settings + + if not settings.conventions_enabled or not project_slug: + return None + try: + from roboco.agents.factories._base import conventions_ambient_layer + from roboco.db.base import get_session_factory + from roboco.services.project import get_project_service + + factory = get_session_factory() + async with factory() as db: + project = await get_project_service(db).get_by_slug(project_slug) + return await conventions_ambient_layer(db, project) + except Exception as exc: + logger.warning( + "Conventions ambient resolution failed (non-fatal)", + project_slug=project_slug, + error=str(exc), + ) + return None + async def _readiness_gate(self, agent_id: str, task_id: str | None) -> str | None: """Return a reason string if the spawn must be refused, else None. diff --git a/tests/integration/test_conventions_ambient_resolver.py b/tests/integration/test_conventions_ambient_resolver.py new file mode 100644 index 00000000..3f971209 --- /dev/null +++ b/tests/integration/test_conventions_ambient_resolver.py @@ -0,0 +1,69 @@ +"""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_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, None) is None diff --git a/tests/unit/agents/test_conventions_ambient_injection.py b/tests/unit/agents/test_conventions_ambient_injection.py new file mode 100644 index 00000000..f1981e75 --- /dev/null +++ b/tests/unit/agents/test_conventions_ambient_injection.py @@ -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 From eae3c3cc870c6f7ca1945acb62e964cc57c6d367 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 04:29:19 +0200 Subject: [PATCH 10/29] test(conventions): subprocess CLI smoke for the agent-image entrypoint --- tests/unit/conventions/test_cli_smoke.py | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 tests/unit/conventions/test_cli_smoke.py diff --git a/tests/unit/conventions/test_cli_smoke.py b/tests/unit/conventions/test_cli_smoke.py new file mode 100644 index 00000000..e252ac6c --- /dev/null +++ b/tests/unit/conventions/test_cli_smoke.py @@ -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" From 3b064ac49525a704f7cef7b44b31e8ac7f60bd07 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 04:33:24 +0200 Subject: [PATCH 11/29] feat(conventions): block i_am_done on block-level convention violations --- .../services/gateway/choreographer/_impl.py | 80 +++++++++++++--- roboco/services/git.py | 71 ++++++++++++++ .../test_conventions_gate_i_am_done.py | 94 +++++++++++++++++++ 3 files changed, 231 insertions(+), 14 deletions(-) create mode 100644 tests/unit/gateway/test_conventions_gate_i_am_done.py diff --git a/roboco/services/gateway/choreographer/_impl.py b/roboco/services/gateway/choreographer/_impl.py index 9b9d861c..8b2d2965 100644 --- a/roboco/services/gateway/choreographer/_impl.py +++ b/roboco/services/gateway/choreographer/_impl.py @@ -1651,20 +1651,19 @@ async def _i_am_done_gate(self, ctx: _IAmDoneContext) -> Envelope | None: Returns the rejection envelope if any gate fails; None on pass. Shared by the normal and resume-from-verifying paths so both push. """ - if rejection := await self._check_tracing_gates( - ctx.agent_id, ctx.task_id, ctx.task - ): - return await self._reject_i_am_done(ctx, rejection) - if rejection := await self._check_submit_qa_field_gates( - ctx.agent_id, ctx.task_id, ctx.task - ): - return await self._reject_i_am_done(ctx, rejection) - if rejection := await self._ensure_branch_pushed(ctx): - return await self._reject_i_am_done(ctx, rejection) - if rejection := await self._check_quality_gate(ctx): - return await self._reject_i_am_done(ctx, rejection) - if rejection := await self._toolchain_broken_guard(ctx.agent_id, ctx.task): - return await self._reject_i_am_done(ctx, rejection) + guards = ( + lambda: self._check_tracing_gates(ctx.agent_id, ctx.task_id, ctx.task), + lambda: self._check_submit_qa_field_gates( + ctx.agent_id, ctx.task_id, ctx.task + ), + lambda: self._ensure_branch_pushed(ctx), + lambda: self._check_quality_gate(ctx), + lambda: self._toolchain_broken_guard(ctx.agent_id, ctx.task), + lambda: self._conventions_gate(ctx), + ) + for guard in guards: + if rejection := await guard(): + return await self._reject_i_am_done(ctx, rejection) # Pre-gateway parity: persist per-criterion # status now that all gates have passed. The write runs AFTER the # verdict so it cannot change i_am_done's rejection behavior. @@ -1729,6 +1728,59 @@ async def _toolchain_broken_guard( context_briefing={}, ) + async def _conventions_gate(self, ctx: _IAmDoneContext) -> Envelope | None: + """Block i_am_done on unresolved block-level architectural violations. + + Runs the conventions validator on the dev's changed files. A ``block`` + finding (a misplaced definition, a lint suppression) or a validator that + could not run refuses the submit with the offending ``file:line`` and a + fix hint. ``warn`` findings never block. Inert when the flag is off. + """ + from roboco.config import settings as _settings + + if not _settings.conventions_enabled: + return None + result = await self.git.conventions_check_for_task(ctx.agent_id, ctx.task) + return self._conventions_rejection(result, ctx.briefing) + + @staticmethod + def _conventions_rejection( + result: dict[str, Any], briefing: dict[str, Any] + ) -> Envelope | None: + """Turn a validator result into a rejection Envelope, or None to pass.""" + if result.get("could_not_run"): + return Envelope.invalid_state( + message=( + "the architectural-conventions validator could not run on " + "your changed files — this blocks rather than passing silently" + ), + remediate=( + "the validator failed to analyze the diff (a parse or grammar " + "error). resolve it and call the verb again; if it persists, " + "call i_am_blocked" + ), + context_briefing=briefing, + ) + blocks = [f for f in result.get("findings", []) if f.get("level") == "block"] + if not blocks: + return None + listing = "\n".join( + f"- {f.get('file')}:{f.get('line')} — {f.get('fix_hint')}" for f in blocks + ) + return Envelope.invalid_state( + message=( + f"{len(blocks)} architectural-convention violation(s) must be " + "fixed before this can proceed" + ), + remediate=( + "place each definition in the module the architecture map assigns " + "it, then commit and call the verb again. if a finding is a false " + "positive, add a waiver to .roboco/conventions.yml in your branch " + "for the PR to review:\n\n" + listing + ), + context_briefing=briefing, + ) + async def _ensure_branch_pushed(self, ctx: _IAmDoneContext) -> Envelope | None: """Push the task branch to origin before it reaches awaiting_qa. diff --git a/roboco/services/git.py b/roboco/services/git.py index 2c2f57cf..d7b14ac0 100644 --- a/roboco/services/git.py +++ b/roboco/services/git.py @@ -9,9 +9,11 @@ import asyncio import base64 +import json import os import re import subprocess +import sys import time from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass @@ -3720,6 +3722,75 @@ async def commit( "deletions": deletions, } + async def conventions_check_for_task( + self, actor_agent_id: UUID | None, task: Any + ) -> dict[str, Any]: + """Run the conventions validator on a task's changed files. + + Resolves the acting agent's workspace + the branch's changed files and + runs ``python -m roboco.conventions`` over them. Fail-open on a + resolution error (returns no findings, ``could_not_run=False``); the + validator's OWN fail-loud (exit 3) sets ``could_not_run=True`` so the + gate blocks rather than passing on an unanalyzable diff. + """ + try: + branch = task.branch_name + if not branch: + return {"findings": [], "could_not_run": False} + workspace = await self._workspace_for_branch( + branch, actor_agent_id=actor_agent_id + ) + changed = await self.list_changed_files( + branch_name=branch, actor_agent_id=actor_agent_id + ) + except Exception: + return {"findings": [], "could_not_run": False} + if not changed: + return {"findings": [], "could_not_run": False} + return await self._run_conventions_validator(workspace, changed) + + async def conventions_check_pr( + self, project_slug: str, changed_files: list[str], actor_agent_id: UUID | None + ) -> dict[str, Any]: + """Run the conventions validator over an assembled PR's changed files.""" + if not changed_files: + return {"findings": [], "could_not_run": False} + try: + workspace = await self.get_workspace(project_slug, actor_agent_id) + except Exception: + return {"findings": [], "could_not_run": False} + return await self._run_conventions_validator(workspace, changed_files) + + async def _run_conventions_validator( + self, workspace: Path, files: list[str] + ) -> dict[str, Any]: + proc = await asyncio.create_subprocess_exec( + sys.executable, + "-m", + "roboco.conventions", + "check", + "--root", + str(workspace), + "--files", + *files, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + out, err = await proc.communicate() + if proc.returncode != 0: + reason = err.decode(errors="replace").strip() or "validator crashed" + return {"findings": [], "could_not_run": True, "reason": reason[:300]} + findings: list[dict[str, Any]] = [] + for line in out.decode(errors="replace").splitlines(): + stripped = line.strip() + if not stripped: + continue + try: + findings.append(json.loads(stripped)) + except json.JSONDecodeError: + continue + return {"findings": findings, "could_not_run": False, "reason": None} + async def open_conventions_pr( self, project_slug: str, diff --git a/tests/unit/gateway/test_conventions_gate_i_am_done.py b/tests/unit/gateway/test_conventions_gate_i_am_done.py new file mode 100644 index 00000000..a8aa2ca9 --- /dev/null +++ b/tests/unit/gateway/test_conventions_gate_i_am_done.py @@ -0,0 +1,94 @@ +"""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 From d7a60bd937859b10a68b4dcc2f6ab5a7a8fbef9a Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 04:36:34 +0200 Subject: [PATCH 12/29] feat(conventions): block pr_pass on unresolved convention violations --- .../services/gateway/choreographer/_impl.py | 20 +++-- .../gateway/choreographer/_protocol.py | 5 ++ .../services/gateway/choreographer/pr_gate.py | 11 +++ roboco/services/git.py | 12 --- .../gateway/test_conventions_gate_pr_pass.py | 87 +++++++++++++++++++ 5 files changed, 116 insertions(+), 19 deletions(-) create mode 100644 tests/unit/gateway/test_conventions_gate_pr_pass.py diff --git a/roboco/services/gateway/choreographer/_impl.py b/roboco/services/gateway/choreographer/_impl.py index 8b2d2965..08255f8e 100644 --- a/roboco/services/gateway/choreographer/_impl.py +++ b/roboco/services/gateway/choreographer/_impl.py @@ -1729,19 +1729,25 @@ async def _toolchain_broken_guard( ) async def _conventions_gate(self, ctx: _IAmDoneContext) -> Envelope | None: - """Block i_am_done on unresolved block-level architectural violations. + """Block i_am_done on unresolved block-level architectural violations.""" + return await self._conventions_guard(ctx.agent_id, ctx.task, ctx.briefing) - Runs the conventions validator on the dev's changed files. A ``block`` - finding (a misplaced definition, a lint suppression) or a validator that - could not run refuses the submit with the offending ``file:line`` and a - fix hint. ``warn`` findings never block. Inert when the flag is off. + async def _conventions_guard( + self, agent_id: UUID, task: Any, briefing: dict[str, Any] + ) -> Envelope | None: + """Run the conventions validator on the actor's changed files (gated). + + A ``block`` finding (a misplaced definition, a lint suppression) or a + validator that could not run returns a rejection with the offending + ``file:line`` + fix hint. ``warn`` findings never block. Inert when the + flag is off. Shared by the i_am_done and pr_pass gates. """ from roboco.config import settings as _settings if not _settings.conventions_enabled: return None - result = await self.git.conventions_check_for_task(ctx.agent_id, ctx.task) - return self._conventions_rejection(result, ctx.briefing) + result = await self.git.conventions_check_for_task(agent_id, task) + return self._conventions_rejection(result, briefing) @staticmethod def _conventions_rejection( diff --git a/roboco/services/gateway/choreographer/_protocol.py b/roboco/services/gateway/choreographer/_protocol.py index ae953e93..e8379615 100644 --- a/roboco/services/gateway/choreographer/_protocol.py +++ b/roboco/services/gateway/choreographer/_protocol.py @@ -58,6 +58,11 @@ async def _toolchain_broken_guard( ) -> Envelope | None: raise NotImplementedError + async def _conventions_guard( + self, agent_id: UUID, task: Any, briefing: dict[str, Any] + ) -> Envelope | None: + raise NotImplementedError + @classmethod def _free_text_soup( cls, checks: tuple[tuple[str, Any, int], ...] diff --git a/roboco/services/gateway/choreographer/pr_gate.py b/roboco/services/gateway/choreographer/pr_gate.py index 060efcf9..276b9001 100644 --- a/roboco/services/gateway/choreographer/pr_gate.py +++ b/roboco/services/gateway/choreographer/pr_gate.py @@ -245,6 +245,17 @@ async def _gate_decision( task_id=task_id, verb=verb, ) + # A reviewer must not PASS an assembled PR with unresolved block-level + # architectural violations; pr_fail stays available. + if verb == "pr_pass" and ( + conventions := await self._conventions_guard(reviewer_agent_id, t, briefing) + ): + return await self._emit_rejection( + conventions.with_introspection(task=t, role=role_str), + agent_id=reviewer_agent_id, + task_id=task_id, + verb=verb, + ) runner = self._verb_runner() try: t = await runner.run_intent(verb, t, agent, spec_ctx) diff --git a/roboco/services/git.py b/roboco/services/git.py index d7b14ac0..61c7e7c9 100644 --- a/roboco/services/git.py +++ b/roboco/services/git.py @@ -3749,18 +3749,6 @@ async def conventions_check_for_task( return {"findings": [], "could_not_run": False} return await self._run_conventions_validator(workspace, changed) - async def conventions_check_pr( - self, project_slug: str, changed_files: list[str], actor_agent_id: UUID | None - ) -> dict[str, Any]: - """Run the conventions validator over an assembled PR's changed files.""" - if not changed_files: - return {"findings": [], "could_not_run": False} - try: - workspace = await self.get_workspace(project_slug, actor_agent_id) - except Exception: - return {"findings": [], "could_not_run": False} - return await self._run_conventions_validator(workspace, changed_files) - async def _run_conventions_validator( self, workspace: Path, files: list[str] ) -> dict[str, Any]: diff --git a/tests/unit/gateway/test_conventions_gate_pr_pass.py b/tests/unit/gateway/test_conventions_gate_pr_pass.py new file mode 100644 index 00000000..30bda5fc --- /dev/null +++ b/tests/unit/gateway/test_conventions_gate_pr_pass.py @@ -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 From bf7a7693bda9f0df420f459fa6370274278ada95 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 04:39:15 +0200 Subject: [PATCH 13/29] feat(conventions): surface convention findings into QA evidence --- roboco/services/gateway/choreographer/qa.py | 25 +++++- roboco/services/gateway/evidence_builder.py | 8 +- .../test_conventions_in_qa_evidence.py | 83 +++++++++++++++++++ 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 tests/unit/gateway/test_conventions_in_qa_evidence.py diff --git a/roboco/services/gateway/choreographer/qa.py b/roboco/services/gateway/choreographer/qa.py index 2263e951..d5853c07 100644 --- a/roboco/services/gateway/choreographer/qa.py +++ b/roboco/services/gateway/choreographer/qa.py @@ -164,7 +164,7 @@ async def claim_review(self, qa_agent_id: UUID, task_id: UUID) -> Envelope: t = await self.task.qa_claim(qa_agent_id, task_id) await self.task.mark_evidence_inspected(task_id) - ev = await self._build_qa_claim_evidence(t, task_id) + ev = await self._build_qa_claim_evidence(qa_agent_id, t, task_id) return Envelope.ok( status=str(t.status), task_id=str(task_id), @@ -173,7 +173,26 @@ async def claim_review(self, qa_agent_id: UUID, task_id: UUID) -> Envelope: context_briefing=briefing, ).with_introspection(task=t, role=role_str) - async def _build_qa_claim_evidence(self, t: Any, task_id: UUID) -> Any: + async def _qa_convention_findings( + self, qa_agent_id: UUID, t: Any + ) -> list[dict[str, Any]]: + """Convention-validator findings on the task's changed files (flag-gated). + + Empty when the subsystem is off; a validator that could not run surfaces + a single explicit ``could_not_run`` entry rather than being dropped, so + QA never mistakes a silent failure for a clean diff. + """ + if not settings.conventions_enabled: + return [] + result = await self.git.conventions_check_for_task(qa_agent_id, t) + if result.get("could_not_run"): + reason = result.get("reason") or "validator could not run" + return [{"could_not_run": True, "reason": reason}] + return list(result.get("findings", [])) + + async def _build_qa_claim_evidence( + self, qa_agent_id: UUID, t: Any, task_id: UUID + ) -> Any: """Assemble the inline evidence payload returned by claim_review. Bundles files_changed + pr_diff_summary (both from git, the @@ -194,11 +213,13 @@ async def _build_qa_claim_evidence(self, t: Any, task_id: UUID) -> Any: journal_highlights = await self.evidence_repo.journal_highlights_for_task( task_id ) + convention_findings = await self._qa_convention_findings(qa_agent_id, t) return build_evidence_for_task( t, journal_highlights=journal_highlights, files_changed=files_changed, pr_diff_summary=diff_summary, + convention_findings=convention_findings, ) async def _verify_qa_owner( diff --git a/roboco/services/gateway/evidence_builder.py b/roboco/services/gateway/evidence_builder.py index 3965a986..ff053d21 100644 --- a/roboco/services/gateway/evidence_builder.py +++ b/roboco/services/gateway/evidence_builder.py @@ -10,7 +10,7 @@ from __future__ import annotations -from dataclasses import asdict, dataclass +from dataclasses import asdict, dataclass, field from typing import Any BRIEFING_LIST_CAP = 10 @@ -26,6 +26,10 @@ class EvidencePayload: dev_summary: str | None journal_highlights: list[dict[str, Any]] acceptance_criteria_status: list[dict[str, Any]] + # Architectural-conventions validator findings on the changed files, so QA + # can flag a misplaced definition / suppression. Empty when the subsystem + # is off; a single ``could_not_run`` entry surfaces a fail-loud explicitly. + convention_findings: list[dict[str, Any]] = field(default_factory=list) def as_dict(self) -> dict[str, Any]: return asdict(self) @@ -57,6 +61,7 @@ def build_evidence_for_task( journal_highlights: list[dict[str, Any]], files_changed: list[str], pr_diff_summary: str | None = None, + convention_findings: list[dict[str, Any]] | None = None, ) -> EvidencePayload: """Compose an EvidencePayload from a Task model + supplemental data.""" return EvidencePayload( @@ -68,6 +73,7 @@ def build_evidence_for_task( dev_summary=task.dev_notes, journal_highlights=list(journal_highlights), acceptance_criteria_status=list(task.acceptance_criteria_status or []), + convention_findings=list(convention_findings or []), ) diff --git a/tests/unit/gateway/test_conventions_in_qa_evidence.py b/tests/unit/gateway/test_conventions_in_qa_evidence.py new file mode 100644 index 00000000..940b2fff --- /dev/null +++ b/tests/unit/gateway/test_conventions_in_qa_evidence.py @@ -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"] == [] From 6b24f4f07d4c1835b848609ca3a8592c89ca3332 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 04:42:17 +0200 Subject: [PATCH 14/29] docs(prompts): convention awareness for PO/Intake/Dev/QA/PR-reviewer --- agents/prompts/base.md | 2 +- agents/prompts/roles/board.md | 2 ++ agents/prompts/roles/developer.md | 1 + agents/prompts/roles/pr_reviewer.md | 1 + agents/prompts/roles/prompter.md | 1 + agents/prompts/roles/qa.md | 1 + 6 files changed, 7 insertions(+), 1 deletion(-) diff --git a/agents/prompts/base.md b/agents/prompts/base.md index a529bc6b..0c549399 100644 --- a/agents/prompts/base.md +++ b/agents/prompts/base.md @@ -18,7 +18,7 @@ Every verb returns a JSON envelope. There are exactly two shapes: The envelope's top-level `error` is one of four categories: - `tracing_gap` — a precondition (commit, PR, journal entry, plan, etc.) is missing. Look at `missing` for the literal field key. See the cheatsheet below. -- `invalid_state` — task is in a status that doesn't allow this verb (e.g. cannot `start` a `cancelled` task). The `message` names the actual status. Common phrasings: "task X is in ; cannot start work", "task X is in , expected awaiting_qa for review", "parent task X is in pending; must be in_progress to accept subtasks", "claim failed", "start failed for task X", "fail_review requires at least one issue", "no commits on this task yet", "parent already has N subtasks; cap is 12". +- `invalid_state` — task is in a status that doesn't allow this verb (e.g. cannot `start` a `cancelled` task). The `message` names the actual status. Common phrasings: "task X is in ; cannot start work", "task X is in , expected awaiting_qa for review", "parent task X is in pending; must be in_progress to accept subtasks", "claim failed", "start failed for task X", "fail_review requires at least one issue", "no commits on this task yet", "parent already has N subtasks; cap is 12", "N architectural-convention violation(s) must be fixed" (a definition is in the wrong module per `.roboco/conventions.yml`, or a lint/type suppression slipped in — `remediate` lists each `file:line` + fix; move it, or for a genuine false positive add a `waiver` to `.roboco/conventions.yml` in your branch). - `not_authorized` — your role / assignment / channel-access doesn't permit this. The `message` names the rule. Common phrasings: "not assigned to you", "role 'cell_pm' may not commit code; only developers and documenters write commits", "Cell PM cannot claim code tasks. PMs coordinate, never execute code.", "you are not the assignee of {task_id}; cannot post content to it", "agent '{X}' may not write to channel '{Y}'", "role X cannot send formal notifications". - `not_found` — task / agent / channel id doesn't exist. diff --git a/agents/prompts/roles/board.md b/agents/prompts/roles/board.md index bf164138..9356f3c8 100644 --- a/agents/prompts/roles/board.md +++ b/agents/prompts/roles/board.md @@ -53,6 +53,8 @@ When the briefing carries `company_goals`, that charter is your reference for tr 4. If it's CEO-worthy: `escalate_to_ceo(task_id, reason="...")`. (PO + Head of Marketing only — Auditor cannot escalate; record critical observations as reflect-notes for the CEO to find.) 5. If it's just an observation: `note(scope='reflect', text='...')` and `i_am_idle()`. +When you refine product scope or review a cell's delivery (Product Owner especially), consult the project's architectural map (`.roboco/conventions.yml`) and name the load-bearing placement constraints — which definition kinds live in which modules — so the cells carry them; the standard is enforced at `i_am_done` / `pr_pass`, so scope that ignores it only creates rework. + ## Journaling cadence The Board's journal IS the work product. Most of what you do never produces a verb call — it produces a recorded observation that the CEO and Main PM consume. **Decision and reflect scopes take structured fields — fill them; a flat phrase is a regression.** diff --git a/agents/prompts/roles/developer.md b/agents/prompts/roles/developer.md index 8092d014..52a3b8e5 100644 --- a/agents/prompts/roles/developer.md +++ b/agents/prompts/roles/developer.md @@ -102,6 +102,7 @@ The gateway enforces some of these; the rest are convention but failing one of t 5. ✅ `note(scope='reflect', task_id=...)` walks through every criterion (gateway-enforced as `journal:reflect`). 6. ✅ `open_pr(task_id)` has been called and the response returned a PR number (gateway-enforced via `pr_number` set). 7. ✅ `notes` argument to `i_am_done` is your self-verification summary — what you tested, edge cases considered, anything QA should look at first. +8. ✅ Each definition lives in the module the project's architectural map (`.roboco/conventions.yml`) assigns it and follows the task's `## Constraints` — a Pydantic model belongs in `models/`, not the router; no helpers in routers; no lint/type suppressions. A block-level violation refuses `i_am_done` with the `file:line` + fix; move it, and if a finding is a genuine false positive, add a `waiver` to `.roboco/conventions.yml` in your branch for the PR to review. If any item fails, do not retry `i_am_done`; fix the missing piece first. diff --git a/agents/prompts/roles/pr_reviewer.md b/agents/prompts/roles/pr_reviewer.md index b0ba6164..83c553e1 100644 --- a/agents/prompts/roles/pr_reviewer.md +++ b/agents/prompts/roles/pr_reviewer.md @@ -43,6 +43,7 @@ The PR is from an outside contributor: its code is **untrusted**. Until a human - ❌ Pushing to the contributor's fork, or editing/merging the PR. You review; you never write or merge. - ❌ A trickle of vague comments. Post ONE complete review; each finding names file + line + expected vs actual. - ❌ Approving without reading the full diff. +- ❌ Being lax on the architectural standard. Be mega-strict: on an in-path gate review, a `block`-level convention violation (a definition in the wrong module per `.roboco/conventions.yml`, a helper/model in a router, a lint/type suppression) is an automatic `pr_fail` — the gate already refuses `pr_pass`, and an introduced or expanded `waiver` must be justified in the diff or rejected. Hold placement and house-style to the same bar as correctness. ## When the gateway returns an error diff --git a/agents/prompts/roles/prompter.md b/agents/prompts/roles/prompter.md index 566ac994..e18e1343 100644 --- a/agents/prompts/roles/prompter.md +++ b/agents/prompts/roles/prompter.md @@ -73,6 +73,7 @@ When — and only when — you can write a complete spec: - `team` is the lead cell for single-cell work: one of `backend`, `frontend`, `ux_ui`. `scale` is `single` (one cell) or `multi` (board-led across cells). Each cell's `items` is its ordered list of independently-shippable units (one per intended PR), dependency-first so independent units run in parallel. - Call `propose_draft` only once you're confident — it's what the human reviews and confirms. If the conversation continues and the spec changes, call it again with the updated draft. - Don't call it with a partial or speculative draft just to fill a turn. Prose-only is correct until the spec is real. +- The project's architectural standard (`.roboco/conventions.yml`) is auto-attached to every task as a `## Constraints` section server-side, so you don't restate the generic rules. Do add any *task-specific* placement constraint you learned in the interview — a shared DTO's exact home, a cross-cell contract — to `notes` so each cell builds it in the right module. ## What happens after you call `propose_draft` diff --git a/agents/prompts/roles/qa.md b/agents/prompts/roles/qa.md index 25db1d16..58ecd091 100644 --- a/agents/prompts/roles/qa.md +++ b/agents/prompts/roles/qa.md @@ -79,6 +79,7 @@ The gateway requires `learning` before `pass`/`fail`. Your `notes` argument carr 6. ✅ `note(scope='learning', task_id=...)` written. 7. ✅ For `pass`: `notes` >= 80 chars, names the criteria you verified and the artifact behind each. 8. ✅ For `fail`: each entry in `issues` is concrete and actionable — criterion + file + line + expected/actual. "Doesn't work" is not an issue. +9. ✅ Read `convention_findings` in your `claim_review` evidence — it lists architectural-standard violations on the diff (misplaced definitions, lint suppressions). Flag any block-level finding in your `issues`; a `could_not_run` entry means the validator failed and the placement is unverified, so don't pass on a clean-looking diff. ## Channels From 8f76db2286a7ff1ce41c8bca0049162f5ce1464e Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 04:52:43 +0200 Subject: [PATCH 15/29] feat(conventions): panel Conventions tab + flag toggle + parity --- .../conventions/conventions-tab.tsx | 190 ++++++++++++++++++ .../projects/edit-project-dialog.tsx | 32 ++- .../settings/feature-flags-card.tsx | 2 + panel/src/lib/api/conventions.ts | 92 +++++++++ roboco/api/routes/project.py | 95 ++++++++- roboco/api/schemas/project.py | 28 +++ roboco/services/conventions.py | 6 + roboco/services/settings.py | 1 + .../test_project_conventions_routes.py | 120 +++++++++++ .../policy/conventions/test_ts_parity.py | 28 +++ 10 files changed, 585 insertions(+), 9 deletions(-) create mode 100644 panel/src/components/conventions/conventions-tab.tsx create mode 100644 panel/src/lib/api/conventions.ts create mode 100644 tests/integration/test_project_conventions_routes.py create mode 100644 tests/unit/foundation/policy/conventions/test_ts_parity.py diff --git a/panel/src/components/conventions/conventions-tab.tsx b/panel/src/components/conventions/conventions-tab.tsx new file mode 100644 index 00000000..f0583341 --- /dev/null +++ b/panel/src/components/conventions/conventions-tab.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + conventionsApi, + type ConventionsActionResult, + type ConventionsStandard, + type RuleLevel, +} from "@/lib/api/conventions"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { toast } from "sonner"; + +function actionToast(verb: string, result: ConventionsActionResult): void { + if (result.created && result.pr_number != null) { + toast.success(`${verb}: opened PR #${result.pr_number} on ${result.branch}`); + } else { + toast.success( + `${verb}: prepared on ${result.branch} (no remote PR — workspace not cloned)`, + ); + } +} + +export function ConventionsTab({ projectId }: { projectId: string }) { + const queryClient = useQueryClient(); + const [draft, setDraft] = useState(null); + + const { data, isLoading } = useQuery({ + queryKey: ["conventions", projectId], + queryFn: () => conventionsApi.get(projectId), + }); + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ["conventions", projectId] }); + + const save = useMutation({ + mutationFn: (standard: ConventionsStandard) => + conventionsApi.update(projectId, standard), + onSuccess: (result) => { + actionToast("Saved", result); + setDraft(null); + invalidate(); + }, + onError: (error) => + toast.error( + `Save failed: ${error instanceof Error ? error.message : "unknown error"}`, + ), + }); + + const restore = useMutation({ + mutationFn: () => conventionsApi.restore(projectId), + onSuccess: (result) => { + actionToast("Restore", result); + invalidate(); + }, + onError: (error) => + toast.error( + `Restore failed: ${error instanceof Error ? error.message : "unknown error"}`, + ), + }); + + if (isLoading) { + return ( +

Loading conventions…

+ ); + } + const standard = draft ?? data?.standard ?? null; + if (!standard || !data) { + return ( +

+ No conventions available for this project. +

+ ); + } + + const setRuleLevel = (name: string, level: RuleLevel) => + setDraft({ + ...standard, + rules: { ...standard.rules, [name]: { name, level } }, + }); + + const degraded = data.health.status !== "ok"; + + return ( +
+ {degraded && ( + + + + Conventions degraded — {data.health.status} + + + The committed file is missing or unparseable; the effective map + fell back to the last-good cache plus auto-derived defaults. + + + + )} + + + + Module boundaries + + Which definition kinds are forbidden in each module. + + + + {standard.modules.length === 0 && ( +

No modules mapped yet.

+ )} + {standard.modules.map((module) => ( +
+
+ {module.path}{" "} + — {module.purpose} +
+
+ {module.forbidden.map((kind) => ( + + no {kind} + + ))} +
+
+ ))} +
+
+ + + + Rules + + Toggle a rule between warn (advisory) and block (refuses the gate). + + + + {Object.values(standard.rules).map((rule) => ( +
+ {rule.name.replace(/_/g, " ")} +
+ + {rule.level} + + + setRuleLevel(rule.name, checked ? "block" : "warn") + } + /> +
+
+ ))} +
+
+ +
+ + +
+
+ ); +} diff --git a/panel/src/components/projects/edit-project-dialog.tsx b/panel/src/components/projects/edit-project-dialog.tsx index dd6e612a..0a87b1dc 100644 --- a/panel/src/components/projects/edit-project-dialog.tsx +++ b/panel/src/components/projects/edit-project-dialog.tsx @@ -22,6 +22,13 @@ import { } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { Skeleton } from "@/components/ui/skeleton"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { ConventionsTab } from "@/components/conventions/conventions-tab"; import { Key, KeyRound } from "lucide-react"; import { toast } from "sonner"; import { Team, type ProjectUpdate, type Project } from "@/types"; @@ -342,13 +349,24 @@ export function EditProjectDialog({ projectId, open, onOpenChange }: EditProject
) : project ? ( - // Key forces remount when project changes, resetting form state - onOpenChange(false)} - onCancel={() => onOpenChange(false)} - /> + + + Settings + Conventions + + + {/* Key forces remount when project changes, resetting form state */} + onOpenChange(false)} + onCancel={() => onOpenChange(false)} + /> + + + + + ) : (
Project not found
)} diff --git a/panel/src/components/settings/feature-flags-card.tsx b/panel/src/components/settings/feature-flags-card.tsx index cad04b0a..0e90574b 100644 --- a/panel/src/components/settings/feature-flags-card.tsx +++ b/panel/src/components/settings/feature-flags-card.tsx @@ -28,6 +28,8 @@ const FLAG_DESCRIPTIONS: Record = { provisioning_enabled: "Auto-provision projects from approved pitches.", toolchain_match_enabled: "Provision each agent workspace with the target project's Python (not RoboCo's) and block delivery gates when its test suite can't be executed.", + conventions_enabled: + "Enforce a per-project architectural standard (.roboco/conventions.yml): inject the map, attach baseline constraints, and block i_am_done / pr_pass on misplaced definitions or lint suppressions.", rag_auto_update_enabled: "Keep the knowledge base index refreshed automatically.", transcript_prune_enabled: "Run the background sweep that prunes old transcripts.", }; diff --git a/panel/src/lib/api/conventions.ts b/panel/src/lib/api/conventions.ts new file mode 100644 index 00000000..34f298c2 --- /dev/null +++ b/panel/src/lib/api/conventions.ts @@ -0,0 +1,92 @@ +import api from "./client"; + +// Mirrors roboco.foundation.policy.conventions.models — kept in sync by the +// TS<->Python parity test (tests/unit/foundation/policy/conventions/test_ts_parity.py). +export type RuleLevel = "warn" | "block"; +export type DefinitionKind = + | "model" + | "route" + | "helper" + | "business_logic" + | "component" + | "other"; + +export interface ConventionsModule { + path: string; + purpose: string; + forbidden: DefinitionKind[]; +} + +export interface ConventionsRule { + name: string; + level: RuleLevel; +} + +export interface ConventionsCustomRule { + id: string; + pattern: string; + message: string; + level: RuleLevel; + languages: string[]; +} + +export interface ConventionsWaiver { + path: string; + rule: string; + reason: string; +} + +export interface ConventionsStandard { + version: number; + languages: string[]; + modules: ConventionsModule[]; + rules: Record; + custom: ConventionsCustomRule[]; + waivers: ConventionsWaiver[]; +} + +export interface ConventionsHealth { + status: string; + head_sha: string; + last_ok_sha: string | null; +} + +export interface ConventionsResponse { + standard: ConventionsStandard; + health: ConventionsHealth; +} + +export interface ConventionsActionResult { + pr_number: number | null; + branch: string; + created: boolean; +} + +export const conventionsApi = { + // GET the project's effective map + health. + get: async (projectId: string): Promise => { + const { data } = await api.get( + `/projects/${projectId}/conventions`, + ); + return data; + }, + // PUT an edited standard — opens a PR committing it back (PM+). + update: async ( + projectId: string, + standard: ConventionsStandard, + ): Promise => { + const { data } = await api.put( + `/projects/${projectId}/conventions`, + standard, + ); + return data; + }, + // POST restore — re-commits the file from the last-good map (PM+). + restore: async (projectId: string): Promise => { + const { data } = await api.post( + `/projects/${projectId}/conventions/restore`, + {}, + ); + return data; + }, +}; diff --git a/roboco/api/routes/project.py b/roboco/api/routes/project.py index d6f610ab..ab9710e9 100644 --- a/roboco/api/routes/project.py +++ b/roboco/api/routes/project.py @@ -4,11 +4,14 @@ CRUD operations for managing git projects/repositories. """ -from typing import Annotated, cast +from typing import TYPE_CHECKING, Annotated, cast from uuid import UUID from fastapi import APIRouter, HTTPException, Query, status +if TYPE_CHECKING: + from roboco.db.tables import ProjectTable + from roboco.api.deps import ( CurrentAgentContext, DbSession, @@ -16,6 +19,9 @@ require_pm_or_above, ) from roboco.api.schemas.project import ( + ConventionsActionResponse, + ConventionsHealthResponse, + ConventionsResponse, ProjectCreateRequest, ProjectResponse, ProjectSummaryResponse, @@ -25,9 +31,14 @@ project_to_response, project_to_summary, ) +from roboco.foundation.policy.conventions.models import ConventionsStandard from roboco.models.base import Team from roboco.models.project import ProjectCreate, ProjectUpdate -from roboco.services.project import get_project_service +from roboco.services.conventions import ( + ScaffoldResult, + get_conventions_service, +) +from roboco.services.project import ProjectService, get_project_service router = APIRouter() @@ -423,3 +434,83 @@ async def remove_agent_access( ) return project_to_response(updated) + + +# ============================================================================= +# CONVENTIONS ENDPOINTS +# ============================================================================= + + +async def _get_project_or_404( + service: ProjectService, project_id: str +) -> "ProjectTable": + """Resolve a project by UUID or slug, raising 404 when absent.""" + try: + project = await service.get(UUID(project_id)) + except ValueError: + project = await service.get_by_slug(project_id) + if project is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Project not found: {project_id}", + ) + return project + + +def _action_response(result: ScaffoldResult) -> ConventionsActionResponse: + return ConventionsActionResponse( + pr_number=result.pr_number, branch=result.branch, created=result.created + ) + + +@router.get("/{project_id}/conventions", response_model=ConventionsResponse) +async def get_conventions( + project_id: str, + db: DbSession, + _agent: CurrentAgentContext, +) -> ConventionsResponse: + """Return the project's effective conventions map + its current health.""" + project = await _get_project_or_404(get_project_service(db), project_id) + conv = get_conventions_service(db) + standard = await conv.get_map(project) + health = await conv.health(project) + await db.commit() + return ConventionsResponse( + standard=standard.model_dump(mode="json"), + health=ConventionsHealthResponse( + status=health.status, + head_sha=health.head_sha, + last_ok_sha=health.last_ok_sha, + ), + ) + + +@router.put("/{project_id}/conventions", response_model=ConventionsActionResponse) +async def update_conventions( + project_id: str, + standard: ConventionsStandard, + db: DbSession, + agent: CurrentAgentContext, +) -> ConventionsActionResponse: + """Commit an edited conventions standard back to the repo via a PR (PM+).""" + require_pm_or_above(agent.role, "edit conventions") + project = await _get_project_or_404(get_project_service(db), project_id) + result = await get_conventions_service(db).commit_standard(project, standard) + await db.commit() + return _action_response(result) + + +@router.post( + "/{project_id}/conventions/restore", response_model=ConventionsActionResponse +) +async def restore_conventions( + project_id: str, + db: DbSession, + agent: CurrentAgentContext, +) -> ConventionsActionResponse: + """Re-commit the conventions file from the last-good map via a PR (PM+).""" + require_pm_or_above(agent.role, "restore conventions") + project = await _get_project_or_404(get_project_service(db), project_id) + result = await get_conventions_service(db).restore(project) + await db.commit() + return _action_response(result) diff --git a/roboco/api/schemas/project.py b/roboco/api/schemas/project.py index 3165d8bf..d820e33b 100644 --- a/roboco/api/schemas/project.py +++ b/roboco/api/schemas/project.py @@ -146,6 +146,34 @@ class SyncStateRequest(BaseModel): head_commit: str +# ============================================================================= +# CONVENTIONS +# ============================================================================= + + +class ConventionsHealthResponse(BaseModel): + """Health of a project's architectural-conventions standard.""" + + status: str + head_sha: str + last_ok_sha: str | None + + +class ConventionsResponse(BaseModel): + """The project's effective conventions map + its current health.""" + + standard: dict[str, object] + health: ConventionsHealthResponse + + +class ConventionsActionResponse(BaseModel): + """Result of a scaffold / restore / save — the branch + PR (if opened).""" + + pr_number: int | None + branch: str + created: bool + + # ============================================================================= # CONVERTERS # ============================================================================= diff --git a/roboco/services/conventions.py b/roboco/services/conventions.py index 099535e8..e7d37974 100644 --- a/roboco/services/conventions.py +++ b/roboco/services/conventions.py @@ -128,6 +128,12 @@ async def restore(self, project: ProjectTable) -> ScaffoldResult: mapping = last_good if last_good is not None else self._derive(project) return await self._publish(project, render_yaml(mapping), restore=True) + async def commit_standard( + self, project: ProjectTable, standard: ConventionsStandard + ) -> ScaffoldResult: + """Open a PR committing an externally-edited standard (panel save).""" + return await self._publish(project, render_yaml(standard), restore=False) + async def health(self, project: ProjectTable) -> ConventionsHealth: """Report the standard's status at HEAD + the last-good commit SHA.""" pid = self._pid(project) diff --git a/roboco/services/settings.py b/roboco/services/settings.py index 8e31fb63..7988cfb0 100644 --- a/roboco/services/settings.py +++ b/roboco/services/settings.py @@ -52,6 +52,7 @@ def _validate_bool(value: str) -> None: ("self_heal_originate_enabled", "Self-healing — open fix tasks"), ("provisioning_enabled", "Pitch auto-provisioning"), ("toolchain_match_enabled", "Agent runtime toolchain matching"), + ("conventions_enabled", "Architectural conventions standard"), ("rag_auto_update_enabled", "RAG auto-update"), ("transcript_prune_enabled", "Transcript pruning"), ) diff --git a/tests/integration/test_project_conventions_routes.py b/tests/integration/test_project_conventions_routes.py new file mode 100644 index 00000000..3382cba0 --- /dev/null +++ b/tests/integration/test_project_conventions_routes.py @@ -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 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 diff --git a/tests/unit/foundation/policy/conventions/test_ts_parity.py b/tests/unit/foundation/policy/conventions/test_ts_parity.py new file mode 100644 index 00000000..2a39f5de --- /dev/null +++ b/tests/unit/foundation/policy/conventions/test_ts_parity.py @@ -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 From 3ee5dd9c51521de600e273ebb336f7c584a6bda2 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 04:53:59 +0200 Subject: [PATCH 16/29] test(conventions): end-to-end block, fix, and waiver through the gate --- .../test_conventions_end_to_end.py | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/integration/test_conventions_end_to_end.py diff --git a/tests/integration/test_conventions_end_to_end.py b/tests/integration/test_conventions_end_to_end.py new file mode 100644 index 00000000..50a4a1a5 --- /dev/null +++ b/tests/integration/test_conventions_end_to_end.py @@ -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 From 7555c59d30b26b363902896c1bf245bda6e07a45 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 04:56:42 +0200 Subject: [PATCH 17/29] refactor(conventions): extract pr_pass guards to keep pr_gate under the gate --- .../services/gateway/choreographer/pr_gate.py | 56 ++++++++++++------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/roboco/services/gateway/choreographer/pr_gate.py b/roboco/services/gateway/choreographer/pr_gate.py index 276b9001..652ff2ec 100644 --- a/roboco/services/gateway/choreographer/pr_gate.py +++ b/roboco/services/gateway/choreographer/pr_gate.py @@ -234,28 +234,12 @@ async def _gate_decision( ) if gate is not None: return gate - # A reviewer must not PASS an assembled PR whose suite cannot be run in - # the workspace (interpreter mismatch). pr_fail stays available. - if verb == "pr_pass" and ( - toolchain := await self._toolchain_broken_guard(reviewer_agent_id, t) - ): - return await self._emit_rejection( - toolchain.with_introspection(task=t, role=role_str), - agent_id=reviewer_agent_id, - task_id=task_id, - verb=verb, - ) - # A reviewer must not PASS an assembled PR with unresolved block-level - # architectural violations; pr_fail stays available. - if verb == "pr_pass" and ( - conventions := await self._conventions_guard(reviewer_agent_id, t, briefing) - ): - return await self._emit_rejection( - conventions.with_introspection(task=t, role=role_str), - agent_id=reviewer_agent_id, - task_id=task_id, - verb=verb, + if verb == "pr_pass": + blocked = await self._pr_pass_blocked( + reviewer_agent_id, task_id, t, role_str, briefing ) + if blocked is not None: + return blocked runner = self._verb_runner() try: t = await runner.run_intent(verb, t, agent, spec_ctx) @@ -282,6 +266,36 @@ async def _gate_decision( context_briefing=briefing, ).with_introspection(task=t, role=role_str) + async def _pr_pass_blocked( + self, + reviewer_agent_id: UUID, + task_id: UUID, + t: Any, + role_str: str, + briefing: dict[str, Any], + ) -> Envelope | None: + """Refuse pr_pass on a broken toolchain or a block-level violation. + + A reviewer must not PASS an assembled PR whose suite can't run in the + workspace, or that carries unresolved architectural-convention + violations; pr_fail stays available. Returns the emitted rejection or + None to proceed. Both guards are inert when their flag is off. + """ + guards = ( + lambda: self._toolchain_broken_guard(reviewer_agent_id, t), + lambda: self._conventions_guard(reviewer_agent_id, t, briefing), + ) + for guard in guards: + rejection = await guard() + if rejection is not None: + return await self._emit_rejection( + rejection.with_introspection(task=t, role=role_str), + agent_id=reviewer_agent_id, + task_id=task_id, + verb="pr_pass", + ) + return None + async def _post_gate_review_to_pr( self, t: Any, verb: str, reviewer_slug: str, notes: str ) -> None: From 7da5f01aabded40c0405672df9e1fcb0f9573e02 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 04:58:59 +0200 Subject: [PATCH 18/29] style(conventions): format the baseline-constraints attach in task.create --- roboco/services/task.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/roboco/services/task.py b/roboco/services/task.py index 39983ad2..c3ba7548 100644 --- a/roboco/services/task.py +++ b/roboco/services/task.py @@ -687,9 +687,9 @@ async def _attach_baseline_constraints(self, task: TaskTable) -> None: if project is None: return try: - baseline = await get_conventions_service( - self.session - ).baseline_constraints(project) + baseline = await get_conventions_service(self.session).baseline_constraints( + project + ) except Exception as exc: self.log.warning( "Baseline-constraints attach failed (non-fatal)", From 306443eca40ee6834bf95b380e6a974e48a157ca Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 05:00:41 +0200 Subject: [PATCH 19/29] test(conventions): type-annotate test helpers for the full mypy gate --- tests/integration/test_project_conventions_routes.py | 2 +- tests/integration/test_task_baseline_constraints.py | 6 +++--- tests/unit/conventions/test_placement.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/integration/test_project_conventions_routes.py b/tests/integration/test_project_conventions_routes.py index 3382cba0..8826a6cd 100644 --- a/tests/integration/test_project_conventions_routes.py +++ b/tests/integration/test_project_conventions_routes.py @@ -73,7 +73,7 @@ async def _make_project(client: AsyncClient) -> str: }, ) assert resp.status_code == HTTPStatus.CREATED - return resp.json()["id"] + return str(resp.json()["id"]) async def test_get_conventions_returns_map_and_health(client: AsyncClient) -> None: diff --git a/tests/integration/test_task_baseline_constraints.py b/tests/integration/test_task_baseline_constraints.py index 2616df0b..595561e2 100644 --- a/tests/integration/test_task_baseline_constraints.py +++ b/tests/integration/test_task_baseline_constraints.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from uuid import uuid4 +from uuid import UUID, uuid4 from roboco.config import settings from roboco.db.tables import AgentTable, ProjectTable @@ -54,11 +54,11 @@ def _req( description=description, acceptance_criteria=["it works"], team=Team.BACKEND, - created_by=agent.id, + created_by=UUID(str(agent.id)), task_type=TaskType.CODE, nature=TaskNature.TECHNICAL, estimated_complexity=Complexity.MEDIUM, - project_id=project.id, + project_id=UUID(str(project.id)), ) diff --git a/tests/unit/conventions/test_placement.py b/tests/unit/conventions/test_placement.py index 259bdac7..f82afc7a 100644 --- a/tests/unit/conventions/test_placement.py +++ b/tests/unit/conventions/test_placement.py @@ -4,7 +4,7 @@ import json -from roboco.conventions.placement import check_placement +from roboco.conventions.placement import Definition, check_placement from roboco.foundation.policy.conventions.models import ( ConventionsStandard, Module, @@ -12,7 +12,7 @@ ) _MODEL_LINE = 2 -_DEFS = [("UserCreate", _MODEL_LINE, "model")] +_DEFS: list[Definition] = [("UserCreate", _MODEL_LINE, "model")] def test_forbidden_kind_in_module_is_flagged() -> None: From 3b18f1d341567c658642a5dabb2a72a90c87033c Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 05:16:39 +0200 Subject: [PATCH 20/29] build(conventions): ignore types-PyYAML in deptry (mypy-only type stub) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 4e46c9be..0a3daeac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -405,6 +405,7 @@ DEP002 = [ # Type stubs (used by mypy) "types-passlib", "types-python-jose", + "types-PyYAML", # Documentation (CLI tools) "mkdocs", "mkdocs-material", From f5d418d60631391f6b050fd75ce97a2e47c4eb87 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 05:29:43 +0200 Subject: [PATCH 21/29] docs(conventions): document the standard in CLAUDE.md + PM prompt awareness --- CLAUDE.md | 12 +++++++++++- agents/prompts/roles/cell_pm.md | 2 ++ agents/prompts/roles/main_pm.md | 2 ++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9fcadd55..4ed39680 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -373,7 +373,17 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider` **Self-healing CI loop (default-off).** RoboCo can watch its own repository's CI (a single named workflow) and, on a detected regression, open a fix task that is held out of dispatch until the CEO approves it (it terminates at `awaiting_ceo_approval`), then dispatch it through the normal delivery flow. It is dormant by default and armed by `ROBOCO_SELF_HEAL_ENABLED` plus a second opt-in `ROBOCO_SELF_HEAL_ORIGINATE_ENABLED`; origination is bounded by `ROBOCO_SELF_HEAL_MAX_OPEN_TASKS` / `_MAX_PER_CYCLE` so it can't flood the backlog. It never auto-merges or self-deploys (`roboco/services/self_heal_engine.py`). -**Feature flags / company-in-a-box.** Env-gated, default-off subsystems toggle from the panel's Settings → Feature Flags card (`panel/src/components/settings/feature-flags-card.tsx`) instead of hand-editing env: web research (`ROBOCO_RESEARCH_ENABLED`), the strategy engine (`ROBOCO_STRATEGY_ENGINE_ENABLED`), pitch provisioning (`ROBOCO_PROVISIONING_*`), external / internal PR review, and the self-heal flags above. A toggle persists in the settings store and takes effect on the next backend restart; an unset flag falls back to its environment / config default. +**Feature flags / company-in-a-box.** Env-gated, default-off subsystems toggle from the panel's Settings → Feature Flags card (`panel/src/components/settings/feature-flags-card.tsx`) instead of hand-editing env: web research (`ROBOCO_RESEARCH_ENABLED`), the strategy engine (`ROBOCO_STRATEGY_ENGINE_ENABLED`), pitch provisioning (`ROBOCO_PROVISIONING_*`), external / internal PR review, the agent-runtime toolchain match (`ROBOCO_TOOLCHAIN_MATCH_ENABLED`), the architectural-conventions standard (`ROBOCO_CONVENTIONS_ENABLED`), and the self-heal flags above. A toggle persists in the settings store and takes effect on the next backend restart; an unset flag falls back to its environment / config default. + +## Architectural Conventions Standard + +**Per-project architectural standard (default-off).** Beyond the `make`-style gates (which check syntax/types/tests, not *where code lives*), each project can carry a repo-canonical `.roboco/conventions.yml` — an architecture map (which definition *kinds* belong in which modules), a toggleable rule set, custom regex rules, and waivers — so an agent cannot land a Pydantic model defined inside a router, a helper in a route file, or a `# noqa` / `# type: ignore`. Gated by `ROBOCO_CONVENTIONS_ENABLED`; fully inert when off. + +**Effective map.** Consumers read the *effective* map — auto-derived defaults (from a repo scan + `BUILTIN_RULES`) overlaid by the committed file — so behaviour is identical whether the file is present, absent, or partial. `ConventionsService` (`roboco/services/conventions.py`) builds it, caches it per `(project, HEAD sha)` in `project_conventions_cache` (migration `043`), renders the per-task baseline constraints + the ambient prompt block, and scaffolds/restores the file via a PR (`GitService.open_conventions_pr`). The schema lives in `roboco/foundation/policy/conventions/` (pure). + +**Validator.** A single Python CLI, `python -m roboco.conventions check --root --files
...` (`roboco/conventions/`), uses tree-sitter (Python + TypeScript grammars, shipped in the agent image) to classify each changed definition and flag forbidden placements + hygiene + custom-rule matches as JSONL findings, after waiver filtering. Precision over recall (it abstains when uncertain so a `block` gate can't false-positive-strand a task) and fail-loud (a validator that cannot run exits 3 so the gate blocks, never silently passes). + +**Threading + enforcement.** The standard reaches the work two ways: an ambient "Architectural Standard" block injected at spawn (`compose_prompt`) and an auto-attached `## Constraints` section on every project task (`TaskService.create`). Enforcement is deterministic: a `block`-level finding refuses `i_am_done` (dev pre-submit) and `pr_pass` (the in-path PR gate) with the offending `file:line` + fix hint; findings also surface in QA's `claim_review` evidence (`convention_findings`). A false positive is relieved by a `waiver` the dev commits in their branch — accountable, reviewed in the PR. The panel's per-project Conventions tab (in the edit-project dialog) shows the map + health and offers Save / Restore. ## Services diff --git a/agents/prompts/roles/cell_pm.md b/agents/prompts/roles/cell_pm.md index 5b7c4775..cdc168d5 100644 --- a/agents/prompts/roles/cell_pm.md +++ b/agents/prompts/roles/cell_pm.md @@ -8,6 +8,8 @@ You are a coordinator. You receive a task from Main PM, you break it into focuse You merge what your developers submit (leaf PRs into your cell branch via `complete`), and you submit your cell branch up to Main PM via `submit_up`. You never merge to master — that is the CEO's seat. +When the architectural-conventions standard is on, every subtask you `delegate` already carries the project's placement constraints (auto-attached as a `## Constraints` section), and your `submit_up` cell PR runs the conventions gate — a definition in the wrong module per `.roboco/conventions.yml` or a lint/type suppression makes the PR reviewer `pr_fail` your branch. So before you `complete` a dev's leaf or `submit_up`, confirm the work sits where the architecture map says; never ship a block-level violation up the chain. + When the briefing carries `company_goals`, let the charter guide how you scope and prioritize the subtasks you cut: favour decomposition that advances the stated objectives and respects the constraints. ## Inputs you start with diff --git a/agents/prompts/roles/main_pm.md b/agents/prompts/roles/main_pm.md index 5341558a..20ef5736 100644 --- a/agents/prompts/roles/main_pm.md +++ b/agents/prompts/roles/main_pm.md @@ -4,6 +4,8 @@ You are a coordinator at the org level. You receive a root task from the Board or CEO, you decide which cells need to work on it, you delegate ONE subtask per cell to that cell's PM (`be-pm`, `fe-pm`, `ux-pm`), and once those cell-PMs come back with merged work you open the master PR and escalate the root to the CEO. That is the entire job. +When the architectural-conventions standard is on, each cell's task already carries the standard's placement constraints, and your `submit_root` master PR runs the conventions gate — a block-level placement or hygiene violation in the assembled diff (a definition in the wrong module per `.roboco/conventions.yml`, a lint/type suppression) makes the PR reviewer `pr_fail` it before the CEO ever sees it. Route scope that respects the architecture map, and never escalate a root whose diff carries an unresolved block-level violation. + **You do NOT write code. Ever.** **You do NOT delegate to a developer directly** — every code subtask goes to a Cell PM, who breaks it down further. **You do NOT call `Bash git ...`** — you have no commit verb, and the orchestrator denies raw git anyway. **You do NOT call `i_will_work_on`** — that is the developer's claim verb; yours is `i_will_plan`. **You do NOT merge to master** — that is the CEO's seat. If a Cell PM escalates a blocker to you, your job is to fix the *delegation problem* (clarify scope, reassign, unblock) — not to "just do the change yourself". If you find yourself reaching for `Edit`, `Write`, or `Bash git`, stop — you are about to step out of role; the right move is `unblock`, `delegate`, or `escalate_up`. You merge what your Cell PMs submit (cell PRs into your root branch via `complete`). When all cell-PM subtasks are terminal, you open the master PR via `complete` on the root task, which transitions it to `awaiting_ceo_approval`. The CEO approves and merges to master. From c79ca4d4c697802c908d7b793d062c9acf01580c Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 05:29:44 +0200 Subject: [PATCH 22/29] fix(conventions): baseline constraints are non-suppressible (dedup-append) --- roboco/services/task.py | 44 +++++++++++-------- .../test_task_baseline_constraints.py | 23 ++++++++-- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/roboco/services/task.py b/roboco/services/task.py index c3ba7548..022b9220 100644 --- a/roboco/services/task.py +++ b/roboco/services/task.py @@ -666,17 +666,33 @@ async def _attach_baseline_constraints(self, task: TaskTable) -> None: """Append the project's block-rule constraints to the task description. Server-derived backstop (flag-gated): every project task carries the - hard conventions even if nothing upstream added them. Idempotent — the - rendered ``## Constraints`` section is appended at most once. Best-effort: - a failure never blocks task creation. Imported lazily to avoid the - task -> conventions -> git import chain. + hard conventions even if nothing upstream added them — the layer the + design calls "can't be skipped". Idempotent and non-suppressible: it + appends only baseline constraints not already present (dedup by exact + string), so a second pass adds nothing AND an agent-authored + ``## Constraints`` section can never suppress the mandatory baseline. + Best-effort: a failure never blocks task creation. + """ + baseline = await self._project_baseline_constraints(task) + if not baseline: + return + existing = task.description or "" + missing = [item for item in baseline if item not in existing] + if not missing: + return + section = "## Constraints\n" + "\n".join(f"- {item}" for item in missing) + task.description = f"{existing}\n\n{section}" if existing else section + await self.session.flush() + + async def _project_baseline_constraints(self, task: TaskTable) -> list[str]: + """The project's baseline constraints, or ``[]`` (flag-off / none / error). + + Imported lazily to avoid the task -> conventions -> git import chain. """ from roboco.config import settings if not settings.conventions_enabled or task.project_id is None: - return - if task.description and "## Constraints" in task.description: - return + return [] from roboco.services.conventions import get_conventions_service from roboco.services.project import get_project_service @@ -685,9 +701,9 @@ async def _attach_baseline_constraints(self, task: TaskTable) -> None: UUID(str(task.project_id)) ) if project is None: - return + return [] try: - baseline = await get_conventions_service(self.session).baseline_constraints( + return await get_conventions_service(self.session).baseline_constraints( project ) except Exception as exc: @@ -696,15 +712,7 @@ async def _attach_baseline_constraints(self, task: TaskTable) -> None: task_id=str(task.id), error=str(exc), ) - return - if not baseline: - return - - section = "## Constraints\n" + "\n".join(f"- {item}" for item in baseline) - task.description = ( - f"{task.description}\n\n{section}" if task.description else section - ) - await self.session.flush() + return [] async def external_review_task_exists( self, project_id: UUID, pr_number: int, head_sha: str | None = None diff --git a/tests/integration/test_task_baseline_constraints.py b/tests/integration/test_task_baseline_constraints.py index 595561e2..8749449f 100644 --- a/tests/integration/test_task_baseline_constraints.py +++ b/tests/integration/test_task_baseline_constraints.py @@ -82,12 +82,29 @@ async def test_flag_off_attaches_nothing( assert task.description == "Do the work" -async def test_attach_is_idempotent_when_section_present( +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- already here" + 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 task.description.count("## Constraints") == 1 + 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 From 1e4bf09ddbaa484ed80cba51b0c6c1bcabdab50b Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 05:42:39 +0200 Subject: [PATCH 23/29] feat(conventions): scaffold on first workspace clone (threaded workspace) --- roboco/services/conventions.py | 39 +++++++--- roboco/services/git.py | 46 ++++++++---- roboco/services/workspace.py | 37 +++++++++ tests/integration/test_conventions_service.py | 10 ++- tests/integration/test_git_conventions_pr.py | 2 - .../test_workspace_conventions_scaffold.py | 75 +++++++++++++++++++ 6 files changed, 179 insertions(+), 30 deletions(-) create mode 100644 tests/unit/services/test_workspace_conventions_scaffold.py diff --git a/roboco/services/conventions.py b/roboco/services/conventions.py index e7d37974..9a324139 100644 --- a/roboco/services/conventions.py +++ b/roboco/services/conventions.py @@ -28,14 +28,14 @@ ConventionsStandard, ) from roboco.services.base import BaseService -from roboco.services.git import get_git_service +from roboco.services.git import CONVENTIONS_SCAFFOLD_BRANCH, get_git_service if TYPE_CHECKING: from sqlalchemy.ext.asyncio import AsyncSession from roboco.db.tables import ProjectTable -_SCAFFOLD_BRANCH = "chore/roboco-conventions-scaffold" +_SCAFFOLD_BRANCH = CONVENTIONS_SCAFFOLD_BRANCH _AMBIENT_CHAR_CAP = 1200 @@ -117,22 +117,36 @@ async def render_ambient_block(self, project: ProjectTable) -> str: return text[: _AMBIENT_CHAR_CAP - 1].rstrip() + "…" return text - async def scaffold(self, project: ProjectTable) -> ScaffoldResult: + async def scaffold( + self, project: ProjectTable, *, workspace: Path | None = None + ) -> ScaffoldResult: """Open a PR adding the auto-scaffolded ``.roboco/conventions.yml``.""" mapping = await self.get_map(project) - return await self._publish(project, render_yaml(mapping), restore=False) + return await self._publish( + project, render_yaml(mapping), restore=False, workspace=workspace + ) - async def restore(self, project: ProjectTable) -> ScaffoldResult: + async def restore( + self, project: ProjectTable, *, workspace: Path | None = None + ) -> ScaffoldResult: """Open a PR re-committing the file from the last-good map (or a scan).""" last_good = await self._latest_ok_map(self._pid(project)) mapping = last_good if last_good is not None else self._derive(project) - return await self._publish(project, render_yaml(mapping), restore=True) + return await self._publish( + project, render_yaml(mapping), restore=True, workspace=workspace + ) async def commit_standard( - self, project: ProjectTable, standard: ConventionsStandard + self, + project: ProjectTable, + standard: ConventionsStandard, + *, + workspace: Path | None = None, ) -> ScaffoldResult: """Open a PR committing an externally-edited standard (panel save).""" - return await self._publish(project, render_yaml(standard), restore=False) + return await self._publish( + project, render_yaml(standard), restore=False, workspace=workspace + ) async def health(self, project: ProjectTable) -> ConventionsHealth: """Report the standard's status at HEAD + the last-good commit SHA.""" @@ -188,7 +202,12 @@ def _read_committed_standard( return None, "degraded" async def _publish( - self, project: ProjectTable, content: str, *, restore: bool + self, + project: ProjectTable, + content: str, + *, + restore: bool, + workspace: Path | None = None, ) -> ScaffoldResult: action = "restore" if restore else "scaffold" title = f"chore(conventions): {action} .roboco/conventions.yml" @@ -200,9 +219,9 @@ async def _publish( result = await git.open_conventions_pr( project.slug, content=content, - branch=_SCAFFOLD_BRANCH, title=title, body=body, + workspace=workspace, ) if result is None: return ScaffoldResult( diff --git a/roboco/services/git.py b/roboco/services/git.py index 61c7e7c9..bf8a7aa8 100644 --- a/roboco/services/git.py +++ b/roboco/services/git.py @@ -3784,32 +3784,45 @@ async def open_conventions_pr( project_slug: str, *, content: str, - branch: str, title: str, body: str, + workspace: Path | None = None, ) -> dict[str, Any] | None: - """Commit ``.roboco/conventions.yml`` on ``branch`` and open a PR. + """Commit ``.roboco/conventions.yml`` on the scaffold branch + open a PR. - Best-effort and project-level (no task): writes ``content`` to the - project workspace on a fresh ``branch`` cut from the default branch and - commits it (always), then pushes + opens a PR (only when the project has - a git token + remote). Returns ``{"branch", "pr_number", "pr_url"}`` with - ``pr_number=None`` when the remote PR could not be opened, or ``None`` - when the project has no usable workspace to scaffold into. + Best-effort and project-level (no task): writes ``content`` on a fresh + ``CONVENTIONS_SCAFFOLD_BRANCH`` cut from the default branch in + ``workspace`` (an agent's fresh clone) or the project's configured + ``workspace_path``, commits it (always), then pushes + opens a PR (only + with a git token + remote), and restores the clone to its original + branch so a shared workspace is never stranded on the scaffold branch. + Returns ``{"branch", "pr_number", "pr_url"}`` (``pr_number=None`` when no + remote PR was opened) or ``None`` when there is no usable workspace. """ project_service = get_project_service(self.session) project = await project_service.get_by_slug(project_slug) - if project is None or not project.workspace_path: + if project is None: return None - workspace = Path(project.workspace_path) - if not workspace.exists(): + ws = workspace or ( + Path(project.workspace_path) if project.workspace_path else None + ) + if ws is None or not ws.exists(): return None base = project.default_branch or "master" - spec = _ConventionsPr(content=content, branch=branch, title=title, body=body) - await self._commit_conventions_file(workspace, base, spec) - return await self._push_and_open_conventions_pr( - project_slug, workspace, base, spec + spec = _ConventionsPr( + content=content, + branch=CONVENTIONS_SCAFFOLD_BRANCH, + title=title, + body=body, ) + original = await self.get_current_branch(ws) + try: + await self._commit_conventions_file(ws, base, spec) + return await self._push_and_open_conventions_pr( + project_slug, ws, base, spec + ) + finally: + await self._run_git(ws, ["checkout", original], check=False) async def _commit_conventions_file( self, workspace: Path, base: str, spec: _ConventionsPr @@ -3859,6 +3872,9 @@ async def _push_and_open_conventions_pr( } +CONVENTIONS_SCAFFOLD_BRANCH = "chore/roboco-conventions-scaffold" + + @dataclass(frozen=True) class _ConventionsPr: """The fields for a project-level conventions scaffold/restore PR.""" diff --git a/roboco/services/workspace.py b/roboco/services/workspace.py index 6305689c..8a35c558 100644 --- a/roboco/services/workspace.py +++ b/roboco/services/workspace.py @@ -188,6 +188,10 @@ def _monotonic() -> float: # trying to clone into the same directory. _ENSURE_WORKSPACE_LOCKS: dict[tuple[str, str], asyncio.Lock] = {} +# Process-level guard so the conventions scaffold (opened on a project's first +# clone) is attempted at most once per project per process, even across agents. +_SCAFFOLD_ATTEMPTED: set[str] = set() + def _ensure_lock_for(project_slug: str, agent_slug: str) -> asyncio.Lock: """Return the asyncio.Lock for a (project, agent) pair, creating lazily.""" @@ -731,8 +735,41 @@ async def ensure_workspace( git_token, agent=agent, ) + await self._maybe_scaffold_conventions(project, project_slug, workspace) return workspace + async def _maybe_scaffold_conventions( + self, project: Any, project_slug: str, workspace: Path + ) -> None: + """Open the conventions scaffold PR on a project's first clone. + + Flag-gated, best-effort, once-per-process: fires only when the standard + file is absent in the fresh clone and we have not already tried for this + project, so a newly registered project gets a starter + ``.roboco/conventions.yml`` — and any failure is swallowed so it can + never affect the clone path. Imported lazily to avoid the + workspace -> conventions -> git import chain. + """ + from roboco.config import settings + + if not settings.conventions_enabled or project_slug in _SCAFFOLD_ATTEMPTED: + return + _SCAFFOLD_ATTEMPTED.add(project_slug) + if (workspace / ".roboco" / "conventions.yml").exists(): + return + try: + from roboco.services.conventions import get_conventions_service + + await get_conventions_service(self.session).scaffold( + project, workspace=workspace + ) + except Exception as exc: + logger.warning( + "Conventions scaffold on first clone failed (non-fatal)", + project=project_slug, + error=str(exc), + ) + async def _clone_repo( self, workspace: Path, diff --git a/tests/integration/test_conventions_service.py b/tests/integration/test_conventions_service.py index cd23e589..32d59e76 100644 --- a/tests/integration/test_conventions_service.py +++ b/tests/integration/test_conventions_service.py @@ -26,10 +26,14 @@ def __init__(self) -> None: self.calls: list[dict[str, Any]] = [] async def open_conventions_pr( - self, project_slug: str, *, content: str, branch: str, **_kwargs: str + self, project_slug: str, *, content: str, **_kwargs: object ) -> dict[str, Any]: - self.calls.append({"slug": project_slug, "content": content, "branch": branch}) - return {"branch": branch, "pr_number": _FAKE_PR_NUMBER, "pr_url": "u"} + 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( diff --git a/tests/integration/test_git_conventions_pr.py b/tests/integration/test_git_conventions_pr.py index 99aa46f4..2fab576d 100644 --- a/tests/integration/test_git_conventions_pr.py +++ b/tests/integration/test_git_conventions_pr.py @@ -71,7 +71,6 @@ async def test_open_conventions_pr_commits_locally_without_remote( result = await git.open_conventions_pr( project.slug, content="version: 1\n", - branch=_SCAFFOLD_BRANCH, title="scaffold", body="b", ) @@ -97,7 +96,6 @@ async def test_open_conventions_pr_returns_none_without_workspace( result = await git.open_conventions_pr( project.slug, content="version: 1\n", - branch=_SCAFFOLD_BRANCH, title="t", body="b", ) diff --git a/tests/unit/services/test_workspace_conventions_scaffold.py b/tests/unit/services/test_workspace_conventions_scaffold.py new file mode 100644 index 00000000..43172958 --- /dev/null +++ b/tests/unit/services/test_workspace_conventions_scaffold.py @@ -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 From e99c71003144f5e6df25c331bee105f702b062ba Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 05:48:31 +0200 Subject: [PATCH 24/29] feat(conventions): multi-project ambient map for PO/Intake (per-product) --- roboco/agents/factories/_base.py | 32 +++++++-- roboco/runtime/orchestrator.py | 70 +++++++++++++++++-- .../test_conventions_ambient_resolver.py | 18 ++++- 3 files changed, 104 insertions(+), 16 deletions(-) diff --git a/roboco/agents/factories/_base.py b/roboco/agents/factories/_base.py index ea2a8bb4..9ab2660a 100644 --- a/roboco/agents/factories/_base.py +++ b/roboco/agents/factories/_base.py @@ -253,21 +253,41 @@ def compose_prompt( return "\n\n---\n\n".join(parts) +_AMBIENT_TOTAL_CAP = 3000 + + async def conventions_ambient_layer( - session: "AsyncSession", project: "ProjectTable | None" + session: "AsyncSession", projects: "list[ProjectTable]" ) -> str | None: - """Render the project's architectural-standard prompt block (flag-gated). + """Render the architectural-standard block for the in-scope project(s). - Returns None when the conventions subsystem is off or no project is in - scope, so a flag-off / board-level spawn injects nothing. + Merges one block per project (a PO / Intake working a product spans several + per-cell projects; a delivery role has one), each headed by its slug when + there is more than one, bounded to a total cap. Returns None when the + conventions subsystem is off or no project is in scope, so a flag-off / + board-level / cross-product spawn injects nothing. """ from roboco.config import settings - if not settings.conventions_enabled or project is None: + if not settings.conventions_enabled or not projects: return None from roboco.services.conventions import get_conventions_service - return await get_conventions_service(session).render_ambient_block(project) + service = get_conventions_service(session) + blocks: list[str] = [] + for project in projects: + block = await service.render_ambient_block(project) + if not block: + continue + blocks.append( + f"### Project `{project.slug}`\n{block}" if len(projects) > 1 else block + ) + if not blocks: + return None + text = "\n\n".join(blocks) + if len(text) > _AMBIENT_TOTAL_CAP: + return text[: _AMBIENT_TOTAL_CAP - 1].rstrip() + "…" + return text def make_slug(name: str) -> str: diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index fc7770e5..9aba5203 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -1637,7 +1637,7 @@ async def _prepare_agent_spawn( ) -> tuple[AgentConfig, AgentInstance, Path | None]: """Build AgentConfig + AgentInstance and surface per-agent settings path.""" project_slug = self._resolve_project_slug(git_context, agent_id, task_id) - ambient = await self._resolve_conventions_ambient(project_slug) + ambient = await self._resolve_conventions_ambient(project_slug, task_id) blueprint_path = self._generate_composed_prompt(agent_id, ambient=ambient) canonical_role = get_agent_role(agent_id) team = get_agent_team(agent_id) or "backend" @@ -2490,27 +2490,37 @@ def _generate_composed_prompt( return prompt_path async def _resolve_conventions_ambient( - self, project_slug: str | None + self, + project_slug: str | None, + task_id: str | None = None, + product_id: str | None = None, ) -> str | None: """Resolve the architectural-standard ambient block for the spawn. + Covers a delivery role's single project (via ``project_slug``) AND a + PO / Intake working a product, whose per-cell projects are resolved from + the task's ``product_id`` or a directly-supplied ``product_id``. Best-effort + flag-gated: returns None (no ambient layer) when the subsystem is off, no project is in scope, or anything fails — a prompt compose must never be blocked by conventions resolution. """ from roboco.config import settings - if not settings.conventions_enabled or not project_slug: + if not settings.conventions_enabled: return None try: from roboco.agents.factories._base import conventions_ambient_layer from roboco.db.base import get_session_factory - from roboco.services.project import get_project_service factory = get_session_factory() async with factory() as db: - project = await get_project_service(db).get_by_slug(project_slug) - return await conventions_ambient_layer(db, project) + projects = await self._resolve_ambient_projects( + db, + project_slug=project_slug, + task_id=task_id, + product_id=product_id, + ) + return await conventions_ambient_layer(db, projects) except Exception as exc: logger.warning( "Conventions ambient resolution failed (non-fatal)", @@ -2519,6 +2529,49 @@ async def _resolve_conventions_ambient( ) return None + async def _resolve_ambient_projects( + self, + db: Any, + *, + project_slug: str | None, + task_id: str | None, + product_id: str | None, + ) -> list[Any]: + """The in-scope projects for the ambient block (single repo or product).""" + if product_id is None and task_id is not None: + product_id = await self._ambient_product_for_task(db, task_id) + if product_id is not None: + return await self._ambient_product_projects(db, product_id) + if project_slug: + from roboco.services.project import get_project_service + + project = await get_project_service(db).get_by_slug(project_slug) + return [project] if project is not None else [] + return [] + + @staticmethod + async def _ambient_product_for_task(db: Any, task_id: str) -> str | None: + from uuid import UUID + + from roboco.services.task import get_task_service + + task = await get_task_service(db).get(UUID(task_id)) + if task is not None and task.product_id is not None: + return str(task.product_id) + return None + + @staticmethod + async def _ambient_product_projects(db: Any, product_id: str) -> list[Any]: + from uuid import UUID + + from roboco.services.product import get_product_service + from roboco.services.project import get_project_service + + project_service = get_project_service(db) + ids = await get_product_service(db).distinct_project_ids(UUID(product_id)) + resolved = [await project_service.get(pid) for pid in ids] + return [p for p in resolved if p is not None] + async def _readiness_gate(self, agent_id: str, task_id: str | None) -> str | None: """Return a reason string if the spawn must be refused, else None. @@ -3126,7 +3179,10 @@ async def _spawn_intake_container( cwd, cloned = await self._clone_intake_scope(project_slug, product_id) - prompt_path = self._generate_composed_prompt(INTAKE_AGENT_ID) + ambient = await self._resolve_conventions_ambient( + project_slug, product_id=product_id + ) + prompt_path = self._generate_composed_prompt(INTAKE_AGENT_ID, ambient=ambient) route = await self._resolve_agent_route(INTAKE_AGENT_ID) cli_model = _resolve_agent_cli_model( route.provider_type.value, route.model_name diff --git a/tests/integration/test_conventions_ambient_resolver.py b/tests/integration/test_conventions_ambient_resolver.py index 3f971209..039b1e30 100644 --- a/tests/integration/test_conventions_ambient_resolver.py +++ b/tests/integration/test_conventions_ambient_resolver.py @@ -49,21 +49,33 @@ async def test_ambient_block_when_flag_on( ) -> None: monkeypatch.setattr(settings, "conventions_enabled", True) project = await _seed_project(db_session) - block = await conventions_ambient_layer(db_session, project) + 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 + 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, None) is None + assert await conventions_ambient_layer(db_session, []) is None From c1c3efa86cef238fa8e95c348cf5e55c972045b6 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 05:54:46 +0200 Subject: [PATCH 25/29] feat(conventions): persist findings + violations-feed route (migration 044) --- alembic/versions/044_convention_findings.py | 63 +++++++ roboco/api/routes/project.py | 19 +++ roboco/api/schemas/project.py | 13 ++ roboco/db/tables.py | 41 +++++ roboco/services/conventions.py | 60 ++++++- .../services/gateway/choreographer/_impl.py | 42 ++++- .../integration/test_conventions_findings.py | 160 ++++++++++++++++++ .../test_conventions_gate_i_am_done.py | 17 ++ 8 files changed, 410 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/044_convention_findings.py create mode 100644 tests/integration/test_conventions_findings.py diff --git a/alembic/versions/044_convention_findings.py b/alembic/versions/044_convention_findings.py new file mode 100644 index 00000000..64fbfddb --- /dev/null +++ b/alembic/versions/044_convention_findings.py @@ -0,0 +1,63 @@ +"""Add the project_convention_findings table (violations feed). + +Persists architectural-conventions validator findings per task so the panel +can show recent block/warn violations across a project. Pure schema change; +no backfill. Inert until ``ROBOCO_CONVENTIONS_ENABLED``. + +Revision ID: 044_convention_findings +Revises: 043_conventions_cache +Create Date: 2026-06-22 + +NOTE: revision id is 23 chars — alembic's ``alembic_version.version_num`` is +``VARCHAR(32)`` and a longer id raises at record time. +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "044_convention_findings" +down_revision = "043_conventions_cache" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "project_convention_findings", + sa.Column("id", sa.UUID(as_uuid=True), nullable=False), + sa.Column("project_id", sa.UUID(as_uuid=True), nullable=False), + sa.Column("task_id", sa.UUID(as_uuid=True), nullable=True), + sa.Column("file", sa.String(length=500), nullable=False), + sa.Column("line", sa.Integer(), nullable=False), + sa.Column("rule", sa.String(length=100), nullable=False), + sa.Column("level", sa.String(length=20), nullable=False), + sa.Column("kind", sa.String(length=40), nullable=True), + sa.Column("message", sa.Text(), nullable=False), + sa.Column("detected_at", sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_project_convention_findings_project_id", + "project_convention_findings", + ["project_id"], + ) + op.create_index( + "ix_project_convention_findings_detected_at", + "project_convention_findings", + ["detected_at"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_project_convention_findings_detected_at", + table_name="project_convention_findings", + ) + op.drop_index( + "ix_project_convention_findings_project_id", + table_name="project_convention_findings", + ) + op.drop_table("project_convention_findings") diff --git a/roboco/api/routes/project.py b/roboco/api/routes/project.py index ab9710e9..2c3cc527 100644 --- a/roboco/api/routes/project.py +++ b/roboco/api/routes/project.py @@ -19,6 +19,7 @@ require_pm_or_above, ) from roboco.api.schemas.project import ( + ConventionFinding, ConventionsActionResponse, ConventionsHealthResponse, ConventionsResponse, @@ -514,3 +515,21 @@ async def restore_conventions( result = await get_conventions_service(db).restore(project) await db.commit() return _action_response(result) + + +@router.get( + "/{project_id}/conventions/findings", + response_model=list[ConventionFinding], +) +async def get_conventions_findings( + project_id: str, + db: DbSession, + _agent: CurrentAgentContext, + limit: Annotated[int, Query(ge=1, le=200)] = 50, +) -> list[ConventionFinding]: + """Recent architectural-conventions findings for the project (violations feed).""" + project = await _get_project_or_404(get_project_service(db), project_id) + rows = await get_conventions_service(db).recent_findings( + UUID(str(project.id)), limit + ) + return [ConventionFinding(**row) for row in rows] diff --git a/roboco/api/schemas/project.py b/roboco/api/schemas/project.py index d820e33b..ff0881c9 100644 --- a/roboco/api/schemas/project.py +++ b/roboco/api/schemas/project.py @@ -174,6 +174,19 @@ class ConventionsActionResponse(BaseModel): created: bool +class ConventionFinding(BaseModel): + """One recorded architectural-conventions violation (for the feed).""" + + file: str + line: int + rule: str + level: str + kind: str | None + message: str + task_id: str | None + detected_at: str + + # ============================================================================= # CONVERTERS # ============================================================================= diff --git a/roboco/db/tables.py b/roboco/db/tables.py index be1d209a..caf8e748 100644 --- a/roboco/db/tables.py +++ b/roboco/db/tables.py @@ -2288,3 +2288,44 @@ class ProjectConventionsCacheTable(Base): "project_id", "commit_sha", name="uq_project_conventions_cache_sha" ), ) + + +# ============================================================================= +# PROJECT CONVENTION FINDINGS TABLE (violations feed) +# ============================================================================= + + +class ProjectConventionFindingTable(Base): + """A persisted architectural-conventions finding for the violations feed. + + Records the latest validator findings per task (re-recorded on each check — + ``ConventionsService.record_findings`` replaces a task's rows), so the panel + can show recent block/warn violations across a project and drift stays + visible. Written best-effort from the i_am_done gate in its own committed + session, so a finding that *blocked* the submit is still captured. + """ + + __tablename__ = "project_convention_findings" + + id: Mapped[UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid4 + ) + project_id: Mapped[UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("projects.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + task_id: Mapped[UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) + file: Mapped[str] = mapped_column(String(500), nullable=False) + line: Mapped[int] = mapped_column(Integer, nullable=False) + rule: Mapped[str] = mapped_column(String(100), nullable=False) + level: Mapped[str] = mapped_column(String(20), nullable=False) + kind: Mapped[str | None] = mapped_column(String(40), nullable=True) + message: Mapped[str] = mapped_column(Text, nullable=False) + detected_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(UTC), + nullable=False, + index=True, + ) diff --git a/roboco/services/conventions.py b/roboco/services/conventions.py index 9a324139..25639ddf 100644 --- a/roboco/services/conventions.py +++ b/roboco/services/conventions.py @@ -15,13 +15,16 @@ from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from uuid import UUID -from sqlalchemy import select +from sqlalchemy import delete, select from roboco.conventions.scan import derive_from_scan, render_yaml -from roboco.db.tables import ProjectConventionsCacheTable +from roboco.db.tables import ( + ProjectConventionFindingTable, + ProjectConventionsCacheTable, +) from roboco.foundation.policy.conventions.effective_map import effective_map from roboco.foundation.policy.conventions.models import ( ConventionsParseError, @@ -160,6 +163,57 @@ async def health(self, project: ProjectTable) -> ConventionsHealth: last_ok_sha=last_ok.commit_sha if last_ok is not None else None, ) + async def record_findings( + self, project_id: UUID, task_id: UUID, findings: list[dict[str, Any]] + ) -> None: + """Replace a task's recorded findings with the latest set. Caller commits.""" + await self.session.execute( + delete(ProjectConventionFindingTable).where( + ProjectConventionFindingTable.project_id == project_id, + ProjectConventionFindingTable.task_id == task_id, + ) + ) + for finding in findings: + if not finding.get("file") or not finding.get("rule"): + continue + self.session.add( + ProjectConventionFindingTable( + project_id=project_id, + task_id=task_id, + file=str(finding.get("file", "")), + line=int(finding.get("line", 0)), + rule=str(finding.get("rule", "")), + level=str(finding.get("level", "")), + kind=finding.get("kind"), + message=str(finding.get("message", "")), + ) + ) + await self.session.flush() + + async def recent_findings( + self, project_id: UUID, limit: int = 50 + ) -> list[dict[str, Any]]: + """Recent findings across the project, newest first (for the panel feed).""" + result = await self.session.execute( + select(ProjectConventionFindingTable) + .where(ProjectConventionFindingTable.project_id == project_id) + .order_by(ProjectConventionFindingTable.detected_at.desc()) + .limit(limit) + ) + return [ + { + "file": row.file, + "line": row.line, + "rule": row.rule, + "level": row.level, + "kind": row.kind, + "message": row.message, + "task_id": str(row.task_id) if row.task_id is not None else None, + "detected_at": row.detected_at.isoformat(), + } + for row in result.scalars().all() + ] + # -- internals ---------------------------------------------------------- # @staticmethod diff --git a/roboco/services/gateway/choreographer/_impl.py b/roboco/services/gateway/choreographer/_impl.py index 08255f8e..83cdee87 100644 --- a/roboco/services/gateway/choreographer/_impl.py +++ b/roboco/services/gateway/choreographer/_impl.py @@ -1729,8 +1729,46 @@ async def _toolchain_broken_guard( ) async def _conventions_gate(self, ctx: _IAmDoneContext) -> Envelope | None: - """Block i_am_done on unresolved block-level architectural violations.""" - return await self._conventions_guard(ctx.agent_id, ctx.task, ctx.briefing) + """Block i_am_done on unresolved block-level architectural violations. + + Also persists the findings for the panel's violations feed — even the + ones that block this submit. + """ + from roboco.config import settings as _settings + + if not _settings.conventions_enabled: + return None + result = await self.git.conventions_check_for_task(ctx.agent_id, ctx.task) + await self._record_convention_findings(ctx.task, result) + return self._conventions_rejection(result, ctx.briefing) + + @staticmethod + async def _record_convention_findings(task: Any, result: dict[str, Any]) -> None: + """Persist the task's findings for the feed, best-effort. + + Runs in its OWN committed session so a finding that *blocks* the submit + is captured regardless of the verb's transaction outcome. + """ + project_id = getattr(task, "project_id", None) + task_id = getattr(task, "id", None) + if project_id is None or task_id is None: + return + try: + from roboco.db.base import get_session_factory + from roboco.services.conventions import get_conventions_service + + factory = get_session_factory() + async with factory() as db: + await get_conventions_service(db).record_findings( + UUID(str(project_id)), + UUID(str(task_id)), + result.get("findings", []), + ) + await db.commit() + except Exception as exc: + logger.warning( + "Recording convention findings failed (non-fatal)", error=str(exc) + ) async def _conventions_guard( self, agent_id: UUID, task: Any, briefing: dict[str, Any] diff --git a/tests/integration/test_conventions_findings.py b/tests/integration/test_conventions_findings.py new file mode 100644 index 00000000..ed9493a3 --- /dev/null +++ b/tests/integration/test_conventions_findings.py @@ -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"} diff --git a/tests/unit/gateway/test_conventions_gate_i_am_done.py b/tests/unit/gateway/test_conventions_gate_i_am_done.py index a8aa2ca9..1ef7fba6 100644 --- a/tests/unit/gateway/test_conventions_gate_i_am_done.py +++ b/tests/unit/gateway/test_conventions_gate_i_am_done.py @@ -92,3 +92,20 @@ async def test_flag_off_is_inert(monkeypatch: pytest.MonkeyPatch) -> 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 From 448686fbf429728fd1b408b50e9e2a5544b2535e Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 22 Jun 2026 05:56:31 +0200 Subject: [PATCH 26/29] feat(conventions): panel violations feed in the Conventions tab --- .../conventions/conventions-tab.tsx | 37 +++++++++++++++++++ panel/src/lib/api/conventions.ts | 18 +++++++++ 2 files changed, 55 insertions(+) diff --git a/panel/src/components/conventions/conventions-tab.tsx b/panel/src/components/conventions/conventions-tab.tsx index f0583341..a8079690 100644 --- a/panel/src/components/conventions/conventions-tab.tsx +++ b/panel/src/components/conventions/conventions-tab.tsx @@ -39,6 +39,11 @@ export function ConventionsTab({ projectId }: { projectId: string }) { queryFn: () => conventionsApi.get(projectId), }); + const { data: findings } = useQuery({ + queryKey: ["conventions-findings", projectId], + queryFn: () => conventionsApi.findings(projectId), + }); + const invalidate = () => queryClient.invalidateQueries({ queryKey: ["conventions", projectId] }); @@ -168,6 +173,38 @@ export function ConventionsTab({ projectId }: { projectId: string }) { + + + Recent violations + + The latest findings recorded across this project's tasks. + + + + {(!findings || findings.length === 0) && ( +

+ No violations recorded yet. +

+ )} + {(findings ?? []).map((finding, index) => ( +
+
+ + {finding.file}:{finding.line} + {" "} + {finding.message} +
+ + {finding.rule} + +
+ ))} +
+
+