feat(conventions): modularity checks + scan-derived, language-aware rules

The standard was architectural LINTING (placement + hygiene) — things ruff/eslint already do — and it forced backend rules onto frontend projects. This makes it enforce MODULARIZATION, the separation-of-concerns a senior demands that linters are blind to:

- modular_cohesion: a file that mixes architectural concerns (a model defined in a router, a schema in a component) is a monolith — split it. One concern per file.
- thin_routes (Python): a route handler that runs its own DB access instead of delegating to a service.
- thin_components (TypeScript/React): a component that fetches data in its body instead of using a hook.
- god_class: a class past a method-count threshold (single responsibility).

The checks inspect a definition's BODY and a file's COMPOSITION via tree-sitter, precision-over-recall (fire only on a confident structural signal). Rules are now scan-derived and language-aware: hygiene seeds universally, placement only for modules that exist, and modularity per stack — so a frontend project carries no_models_in_components + thin_components, never a backend no_models_in_routers. BUILTIN_RULES is reduced to language-agnostic hygiene.
This commit is contained in:
Renn F
2026-06-22 14:26:25 +02:00
parent 63b6114364
commit 4a8c508f91
7 changed files with 533 additions and 14 deletions
+110
View File
@@ -0,0 +1,110 @@
"""Modularity checks: cohesion, thin routes, thin components, god class."""
from __future__ import annotations
from roboco.conventions import classify_python, classify_ts
from roboco.conventions.modularity import check_modularity
from roboco.foundation.policy.conventions.models import ConventionsStandard
def _py(src: str) -> set[str]:
source = src.encode()
defs = classify_python.classify_definitions(source)
findings = check_modularity(
"app/x.py", defs, source, "python", ConventionsStandard()
)
return {f.rule for f in findings}
def _ts(src: str, lang: str = "tsx") -> set[str]:
source = src.encode()
defs = classify_ts.classify_definitions(source, lang)
findings = check_modularity("src/x.tsx", defs, source, lang, ConventionsStandard())
return {f.rule for f in findings}
# --- Cohesion: one architectural concern per file --------------------------- #
def test_cohesion_flags_model_and_route_in_one_file() -> None:
rules = _py(
"from pydantic import BaseModel\n"
"from fastapi import APIRouter\n"
"router = APIRouter()\n"
"class UserIn(BaseModel):\n"
" name: str\n"
"@router.post('/users')\n"
"def create_user(u):\n"
" return u\n"
)
assert "modular_cohesion" in rules
def test_cohesion_clean_for_a_single_concern() -> None:
rules = _py(
"from fastapi import APIRouter\n"
"router = APIRouter()\n"
"@router.get('/a')\n"
"def a():\n return 1\n"
"@router.get('/b')\n"
"def b():\n return 2\n"
)
assert "modular_cohesion" not in rules
# --- Thin routes: a route must delegate, not query the DB ------------------- #
def test_thin_routes_flags_db_access_in_route() -> None:
rules = _py(
"from fastapi import APIRouter\n"
"router = APIRouter()\n"
"@router.get('/users')\n"
"def list_users(db):\n"
" return db.execute('select 1').scalars().all()\n"
)
assert "thin_routes" in rules
def test_thin_routes_clean_when_delegating_to_a_service() -> None:
rules = _py(
"from fastapi import APIRouter\n"
"router = APIRouter()\n"
"@router.get('/users')\n"
"def list_users(svc):\n"
" return svc.list_users()\n"
)
assert "thin_routes" not in rules
# --- Thin components: data fetching belongs in a hook ----------------------- #
def test_thin_components_flags_fetch_in_component() -> None:
rules = _ts(
"export function UserList() {\n"
" const data = fetch('/api/users');\n"
" return <div>{data}</div>;\n"
"}\n"
)
assert "thin_components" in rules
def test_thin_components_clean_when_presentational() -> None:
rules = _ts(
"export function UserList(props) {\n return <ul>{props.users}</ul>;\n}\n"
)
assert "thin_components" not in rules
# --- God class: single responsibility --------------------------------------- #
def test_god_class_flags_a_class_with_too_many_methods() -> None:
methods = "\n".join(f" def m{i}(self):\n return {i}" for i in range(16))
assert "god_class" in _py("class Big:\n" + methods + "\n")
def test_god_class_clean_for_a_small_class() -> None:
rules = _py("class Small:\n def a(self):\n return 1\n")
assert "god_class" not in rules
@@ -81,11 +81,13 @@ def test_unknown_definition_kind_in_forbidden_raises() -> None:
)
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"
def test_builtin_rules_are_language_agnostic_hygiene_only() -> None:
# BUILTIN_RULES are the universal hygiene defaults; placement / modularity
# rules are derived per project from the scan, never seeded universally.
assert BUILTIN_RULES["no_lint_suppressions"] == "block"
assert BUILTIN_RULES["no_inline_comments"] == "warn"
assert "no_models_in_routers" not in BUILTIN_RULES
assert "no_helpers_in_routers" not in BUILTIN_RULES
def test_models_construct_directly() -> None:
@@ -14,8 +14,10 @@ from roboco.foundation.policy.conventions.models import (
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_lint_suppressions"].level == "block"
assert eff.rules["no_inline_comments"].level == "warn"
# Placement / modularity rules are derived per project, not universal.
assert "no_models_in_routers" not in eff.rules
def test_file_module_overrides_derived_by_path() -> None: