Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down Expand Up @@ -72,6 +73,7 @@ dev = [
# Type Stubs
"types-passlib",
"types-python-jose",
"types-PyYAML",

# Development
"ipython",
Expand Down
10 changes: 10 additions & 0 deletions roboco/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,16 @@ def rag_store_url(self) -> str:
),
)

overload_break_enabled: bool = Field(
default=True,
description=(
"Park a provider on a persistent server overload (HTTP 529 / 500 / "
"503 from the model API) the same way a 429 rate limit is parked: "
"queue that provider's spawns and probe until it recovers, instead "
"of crash-retrying into the overload. Off => crash-retry behavior."
),
)

# ==========================================================================
# Web Research (pluggable external search/fetch for Board + PM roles)
# ==========================================================================
Expand Down
33 changes: 33 additions & 0 deletions roboco/foundation/policy/conventions/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
65 changes: 65 additions & 0 deletions roboco/foundation/policy/conventions/effective_map.py
Original file line number Diff line number Diff line change
@@ -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,
)
129 changes: 129 additions & 0 deletions roboco/foundation/policy/conventions/models.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading