mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
Feature/architectural conventions standard (#243)
* feat(conventions): standard schema models + effective-map merge * feat(conventions): tree-sitter Python classifier + placement checks * feat(conventions): TS classifier, hygiene/custom checks, runner + CLI * feat(conventions): ROBOCO_CONVENTIONS_ENABLED flag + cache table + migration * feat(conventions): repo auto-scan + scaffold draft renderer * feat(conventions): ConventionsService (cache/baseline/ambient/scaffold/restore) * feat(conventions): auto-scaffold on project registration (flag-gated) * feat(conventions): TaskDescription.constraints + auto-baseline attach * feat(conventions): ambient architecture-map injection at spawn * test(conventions): subprocess CLI smoke for the agent-image entrypoint * feat(conventions): block i_am_done on block-level convention violations * feat(conventions): block pr_pass on unresolved convention violations * feat(conventions): surface convention findings into QA evidence * docs(prompts): convention awareness for PO/Intake/Dev/QA/PR-reviewer * feat(conventions): panel Conventions tab + flag toggle + parity * test(conventions): end-to-end block, fix, and waiver through the gate * refactor(conventions): extract pr_pass guards to keep pr_gate under the gate * style(conventions): format the baseline-constraints attach in task.create * test(conventions): type-annotate test helpers for the full mypy gate * build(conventions): ignore types-PyYAML in deptry (mypy-only type stub) * docs(conventions): document the standard in CLAUDE.md + PM prompt awareness * fix(conventions): baseline constraints are non-suppressible (dedup-append) * feat(conventions): scaffold on first workspace clone (threaded workspace) * feat(conventions): multi-project ambient map for PO/Intake (per-product) * feat(conventions): persist findings + violations-feed route (migration 044) * feat(conventions): panel violations feed in the Conventions tab * test(conventions): intake-spawn mock accepts the ambient layer kwarg * fix(docker): ollama-init best-effort pull, gate startup on cached models present A degraded/slow ollama registry made the model manifest re-check fail under set -e, so ollama-init exited 1 and blocked the orchestrator's service_completed_successfully gate — taking the whole stack down even though both models were already cached. Pulls are now best-effort; success is gated on the models being present, so a flaky registry can't down a cached deployment. * refactor(content): drop dead TaskDescription.with_baseline_constraints The structured baseline-merge helper had zero production callers. Project-task baseline constraints are attached by the wired string backstop (TaskService._attach_baseline_constraints), and a real task is free-form prose that cannot form a valid TaskDescription (requires a non-trivial objective + non-empty the_work), so the helper was unreachable from any live path — a leftover from the structured-merge -> string-append design pivot. Removing it leaves a single enforcement path. The constraints field itself stays: it is a member of the well-formed-spec schema (Objective / What This Builds / The Work / Notes / Constraints / Acceptance Criteria), rendered by render_markdown and unit-tested. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
"""Python definition-kind classification (tree-sitter), precision-over-recall."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from roboco.conventions.classify_python import classify_definitions
|
||||
|
||||
|
||||
def test_pydantic_model_is_classified_model() -> None:
|
||||
src = b"from pydantic import BaseModel\nclass UserCreate(BaseModel):\n x: int\n"
|
||||
defs = classify_definitions(src)
|
||||
assert ("UserCreate", 2, "model") in defs
|
||||
|
||||
|
||||
def test_dotted_base_model_is_classified_model() -> None:
|
||||
defs = classify_definitions(b"class M(pydantic.BaseModel):\n pass\n")
|
||||
assert defs == [("M", 1, "model")]
|
||||
|
||||
|
||||
def test_sqlalchemy_declarative_base_is_model() -> None:
|
||||
defs = classify_definitions(b"class Account(Base):\n pass\n")
|
||||
assert defs == [("Account", 1, "model")]
|
||||
|
||||
|
||||
def test_router_decorated_function_is_route() -> None:
|
||||
src = b"@router.get('/x')\ndef list_x():\n return 1\n"
|
||||
defs = classify_definitions(src)
|
||||
assert defs == [("list_x", 2, "route")]
|
||||
|
||||
|
||||
def test_app_post_decorated_function_is_route() -> None:
|
||||
src = b"@app.post('/y')\ndef create_y():\n return 1\n"
|
||||
assert classify_definitions(src) == [("create_y", 2, "route")]
|
||||
|
||||
|
||||
def test_blueprint_get_decorated_function_is_route() -> None:
|
||||
# Any object with an HTTP-method attribute counts as a route handler.
|
||||
src = b"@bp.delete('/z')\ndef drop_z():\n return 1\n"
|
||||
assert classify_definitions(src) == [("drop_z", 2, "route")]
|
||||
|
||||
|
||||
def test_plain_function_is_helper() -> None:
|
||||
assert classify_definitions(b"def helper():\n pass\n") == [
|
||||
("helper", 1, "helper")
|
||||
]
|
||||
|
||||
|
||||
def test_non_route_decorated_function_is_helper() -> None:
|
||||
# A decorator that is not an HTTP route still leaves a plain function.
|
||||
src = b"@functools.cache\ndef compute():\n return 1\n"
|
||||
assert classify_definitions(src) == [("compute", 2, "helper")]
|
||||
|
||||
|
||||
def test_ambiguous_class_abstains_to_other() -> None:
|
||||
assert classify_definitions(b"class Thing:\n pass\n") == [("Thing", 1, "other")]
|
||||
|
||||
|
||||
def test_class_with_unknown_base_abstains() -> None:
|
||||
assert classify_definitions(b"class Widget(Gadget):\n pass\n") == [
|
||||
("Widget", 1, "other")
|
||||
]
|
||||
|
||||
|
||||
def test_multiple_top_level_defs_in_order() -> None:
|
||||
src = (
|
||||
b"from pydantic import BaseModel\n"
|
||||
b"class Req(BaseModel):\n x: int\n"
|
||||
b"@router.put('/u')\ndef upd():\n return 1\n"
|
||||
b"def util():\n pass\n"
|
||||
)
|
||||
defs = classify_definitions(src)
|
||||
assert defs == [
|
||||
("Req", 2, "model"),
|
||||
("upd", 5, "route"),
|
||||
("util", 7, "helper"),
|
||||
]
|
||||
|
||||
|
||||
def test_nested_defs_are_not_top_level() -> None:
|
||||
# Only module-level definitions are classified (precision).
|
||||
src = b"def outer():\n def inner():\n pass\n return inner\n"
|
||||
assert classify_definitions(src) == [("outer", 1, "helper")]
|
||||
@@ -0,0 +1,50 @@
|
||||
"""TypeScript / TSX definition-kind classification, precision-over-recall."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from roboco.conventions.classify_ts import classify_definitions
|
||||
|
||||
|
||||
def test_zod_schema_const_is_model() -> None:
|
||||
src = b"export const UserSchema = z.object({ id: z.string() });\n"
|
||||
assert ("UserSchema", 1, "model") in classify_definitions(src, "typescript")
|
||||
|
||||
|
||||
def test_chained_zod_schema_is_model() -> None:
|
||||
src = b"export const P = z.object({}).partial();\n"
|
||||
assert ("P", 1, "model") in classify_definitions(src, "typescript")
|
||||
|
||||
|
||||
def test_entity_class_is_model() -> None:
|
||||
src = b"@Entity()\nexport class User {}\n"
|
||||
assert ("User", 2, "model") in classify_definitions(src, "typescript")
|
||||
|
||||
|
||||
def test_controller_class_is_route() -> None:
|
||||
src = b"@Controller('users')\nexport class UsersController {}\n"
|
||||
assert ("UsersController", 2, "route") in classify_definitions(src, "typescript")
|
||||
|
||||
|
||||
def test_arrow_component_is_component() -> None:
|
||||
src = b"export const Btn = () => <div/>;\n"
|
||||
assert ("Btn", 1, "component") in classify_definitions(src, "tsx")
|
||||
|
||||
|
||||
def test_function_component_is_component() -> None:
|
||||
src = b"export function Card() { return <span/>; }\n"
|
||||
assert ("Card", 1, "component") in classify_definitions(src, "tsx")
|
||||
|
||||
|
||||
def test_plain_function_abstains_to_other() -> None:
|
||||
src = b"export function add(a: number, b: number) { return a + b; }\n"
|
||||
assert classify_definitions(src, "typescript") == [("add", 1, "other")]
|
||||
|
||||
|
||||
def test_plain_const_abstains_to_other() -> None:
|
||||
src = b"export const TAX = 0.2;\n"
|
||||
assert classify_definitions(src, "typescript") == [("TAX", 1, "other")]
|
||||
|
||||
|
||||
def test_unparseable_source_abstains_quietly() -> None:
|
||||
# tree-sitter yields ERROR nodes; we must not crash or invent findings.
|
||||
assert classify_definitions(b"export const = = =;\n", "typescript") == []
|
||||
@@ -0,0 +1,75 @@
|
||||
"""CLI: JSONL findings on stdout, exit 0 when it ran, exit 3 when it could not."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from roboco.conventions.__main__ import main
|
||||
from roboco.conventions.runner import ValidatorCouldNotRun
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_EXIT_COULD_NOT_RUN = 3
|
||||
|
||||
|
||||
def _seed_repo(root: Path) -> None:
|
||||
routers = root / "app" / "routers"
|
||||
routers.mkdir(parents=True)
|
||||
(routers / "u.py").write_text(
|
||||
"from pydantic import BaseModel\nclass M(BaseModel):\n x: int\n"
|
||||
)
|
||||
conv = root / ".roboco"
|
||||
conv.mkdir()
|
||||
(conv / "conventions.yml").write_text(
|
||||
"modules:\n - path: app/routers\n purpose: r\n forbidden: [model]\n"
|
||||
)
|
||||
|
||||
|
||||
def test_cli_prints_jsonl_and_exits_zero(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
_seed_repo(tmp_path)
|
||||
rc = main(["check", "--root", str(tmp_path), "--files", "app/routers/u.py"])
|
||||
assert rc == 0
|
||||
lines = capsys.readouterr().out.strip().splitlines()
|
||||
assert lines
|
||||
assert json.loads(lines[0])["rule"] == "no_models_in_routers"
|
||||
|
||||
|
||||
def test_cli_exits_zero_with_no_findings(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
(tmp_path / "clean.py").write_text("def helper():\n return 1\n")
|
||||
rc = main(["check", "--root", str(tmp_path), "--files", "clean.py"])
|
||||
assert rc == 0
|
||||
assert capsys.readouterr().out.strip() == ""
|
||||
|
||||
|
||||
def test_cli_exits_three_on_unparseable_config(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
conv = tmp_path / ".roboco"
|
||||
conv.mkdir()
|
||||
(conv / "conventions.yml").write_text("modules: [oops\n")
|
||||
rc = main(["check", "--root", str(tmp_path), "--files"])
|
||||
assert rc == _EXIT_COULD_NOT_RUN
|
||||
assert "error" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_cli_exits_three_when_validator_cannot_run(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
(tmp_path / "x.py").write_text("x = 1\n")
|
||||
|
||||
def boom(*_args: object, **_kw: object) -> list:
|
||||
raise ValidatorCouldNotRun("no grammar")
|
||||
|
||||
monkeypatch.setattr("roboco.conventions.__main__.run", boom)
|
||||
rc = main(["check", "--root", str(tmp_path), "--files", "x.py"])
|
||||
assert rc == _EXIT_COULD_NOT_RUN
|
||||
payload = json.loads(capsys.readouterr().err)
|
||||
assert "error" in payload
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Smoke the real ``python -m roboco.conventions`` entrypoint as a subprocess.
|
||||
|
||||
Guards the contract the agent image depends on: the module runs, loads its
|
||||
tree-sitter grammars, and emits JSONL findings with exit 0.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_cli_module_entrypoint_emits_jsonl(tmp_path: Path) -> None:
|
||||
routers = tmp_path / "app" / "routers"
|
||||
routers.mkdir(parents=True)
|
||||
(routers / "u.py").write_text(
|
||||
"from pydantic import BaseModel\nclass M(BaseModel):\n x: int\n"
|
||||
)
|
||||
conv = tmp_path / ".roboco"
|
||||
conv.mkdir()
|
||||
(conv / "conventions.yml").write_text(
|
||||
"modules:\n - path: app/routers\n purpose: r\n forbidden: [model]\n"
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"roboco.conventions",
|
||||
"check",
|
||||
"--root",
|
||||
str(tmp_path),
|
||||
"--files",
|
||||
"app/routers/u.py",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
lines = [line for line in result.stdout.strip().splitlines() if line]
|
||||
assert lines
|
||||
assert json.loads(lines[0])["rule"] == "no_models_in_routers"
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Custom regex rules, scoped by language."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from roboco.conventions.custom import check_custom
|
||||
from roboco.foundation.policy.conventions.models import ConventionsStandard, CustomRule
|
||||
|
||||
_NO_PRINT = CustomRule(
|
||||
id="no-print",
|
||||
pattern=r"\bprint\(",
|
||||
message="use the logger, not print()",
|
||||
level="warn",
|
||||
languages=["python"],
|
||||
)
|
||||
|
||||
|
||||
def test_custom_rule_matches_in_scoped_language() -> None:
|
||||
std = ConventionsStandard(custom=[_NO_PRINT])
|
||||
findings = check_custom("a.py", b"print('x')\n", "python", std)
|
||||
assert len(findings) == 1
|
||||
assert findings[0].rule == "no-print"
|
||||
assert findings[0].level == "warn"
|
||||
assert findings[0].message == "use the logger, not print()"
|
||||
|
||||
|
||||
def test_custom_rule_skips_other_language() -> None:
|
||||
std = ConventionsStandard(custom=[_NO_PRINT])
|
||||
assert check_custom("a.ts", b"print('x')\n", "typescript", std) == []
|
||||
|
||||
|
||||
def test_unscoped_custom_rule_applies_to_all_languages() -> None:
|
||||
rule = CustomRule(
|
||||
id="no-log", pattern=r"console\.log", message="no console.log", level="warn"
|
||||
)
|
||||
std = ConventionsStandard(custom=[rule])
|
||||
assert check_custom("a.ts", b"console.log(1)\n", "typescript", std)
|
||||
|
||||
|
||||
def test_custom_rule_reports_correct_line() -> None:
|
||||
print_line = 3
|
||||
std = ConventionsStandard(custom=[_NO_PRINT])
|
||||
findings = check_custom("a.py", b"x = 1\ny = 2\nprint(x)\n", "python", std)
|
||||
assert findings[0].line == print_line
|
||||
|
||||
|
||||
def test_bad_regex_abstains_without_crashing() -> None:
|
||||
rule = CustomRule(id="bad", pattern=r"(unclosed", message="m", level="block")
|
||||
std = ConventionsStandard(custom=[rule])
|
||||
assert check_custom("a.py", b"anything\n", "python", std) == []
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Hygiene checks: inline comments + lint/type suppressions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from roboco.conventions.hygiene import check_hygiene
|
||||
from roboco.foundation.policy.conventions.models import ConventionsStandard, Rule
|
||||
|
||||
_STD = ConventionsStandard()
|
||||
|
||||
|
||||
def _rules(findings: list, rule: str) -> list:
|
||||
return [f for f in findings if f.rule == rule]
|
||||
|
||||
|
||||
def test_trailing_comment_is_flagged_inline() -> None:
|
||||
findings = check_hygiene("a.py", b"x = 1 # set x\n", "python", _STD)
|
||||
inline = _rules(findings, "no_inline_comments")
|
||||
assert inline and inline[0].level == "warn"
|
||||
assert inline[0].line == 1
|
||||
|
||||
|
||||
def test_full_line_comment_is_not_inline() -> None:
|
||||
findings = check_hygiene("a.py", b"# a heading\nx = 1\n", "python", _STD)
|
||||
assert _rules(findings, "no_inline_comments") == []
|
||||
|
||||
|
||||
def test_indented_full_line_comment_is_not_inline() -> None:
|
||||
src = b"def f():\n # explain\n return 1\n"
|
||||
findings = check_hygiene("a.py", src, "python", _STD)
|
||||
assert _rules(findings, "no_inline_comments") == []
|
||||
|
||||
|
||||
def test_python_type_ignore_flags_suppression_block() -> None:
|
||||
findings = check_hygiene("a.py", b"y = bad() # type: ignore\n", "python", _STD)
|
||||
sup = _rules(findings, "no_lint_suppressions")
|
||||
assert sup and sup[0].level == "block"
|
||||
|
||||
|
||||
def test_python_noqa_flags_suppression() -> None:
|
||||
findings = check_hygiene("a.py", b"import os # noqa: F401\n", "python", _STD)
|
||||
assert _rules(findings, "no_lint_suppressions")
|
||||
|
||||
|
||||
def test_ts_eslint_disable_flags_suppression() -> None:
|
||||
src = b"// eslint-disable-next-line\nconst x = 1;\n"
|
||||
findings = check_hygiene("a.ts", src, "typescript", _STD)
|
||||
assert _rules(findings, "no_lint_suppressions")
|
||||
|
||||
|
||||
def test_ts_ignore_flags_suppression() -> None:
|
||||
src = b"// @ts-ignore\nconst x: number = 'no';\n"
|
||||
findings = check_hygiene("a.ts", src, "typescript", _STD)
|
||||
assert _rules(findings, "no_lint_suppressions")
|
||||
|
||||
|
||||
def test_python_marker_not_applied_to_typescript() -> None:
|
||||
src = b"// noqa is a python thing\nconst x = 1;\n"
|
||||
findings = check_hygiene("a.ts", src, "typescript", _STD)
|
||||
assert _rules(findings, "no_lint_suppressions") == []
|
||||
|
||||
|
||||
def test_rule_level_override_from_standard() -> None:
|
||||
std = ConventionsStandard(
|
||||
rules={"no_inline_comments": Rule(name="no_inline_comments", level="block")}
|
||||
)
|
||||
findings = check_hygiene("a.py", b"x = 1 # c\n", "python", std)
|
||||
assert _rules(findings, "no_inline_comments")[0].level == "block"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Placement checks: a def whose kind is forbidden in its module is flagged."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from roboco.conventions.placement import Definition, check_placement
|
||||
from roboco.foundation.policy.conventions.models import (
|
||||
ConventionsStandard,
|
||||
Module,
|
||||
Rule,
|
||||
)
|
||||
|
||||
_MODEL_LINE = 2
|
||||
_DEFS: list[Definition] = [("UserCreate", _MODEL_LINE, "model")]
|
||||
|
||||
|
||||
def test_forbidden_kind_in_module_is_flagged() -> None:
|
||||
std = ConventionsStandard(
|
||||
modules=[Module(path="app/routers", purpose="routes", forbidden=["model"])]
|
||||
)
|
||||
findings = check_placement("app/routers/users.py", _DEFS, std)
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert f.kind == "model"
|
||||
assert f.rule == "no_models_in_routers"
|
||||
assert f.level == "block"
|
||||
assert f.line == _MODEL_LINE
|
||||
assert "app/routers" in f.message
|
||||
|
||||
|
||||
def test_allowed_kind_in_module_is_not_flagged() -> None:
|
||||
std = ConventionsStandard(
|
||||
modules=[Module(path="app/models", purpose="models", forbidden=["route"])]
|
||||
)
|
||||
assert check_placement("app/models/user.py", _DEFS, std) == []
|
||||
|
||||
|
||||
def test_no_matching_module_yields_no_finding() -> None:
|
||||
std = ConventionsStandard(
|
||||
modules=[Module(path="app/routers", purpose="routes", forbidden=["model"])]
|
||||
)
|
||||
assert check_placement("lib/helpers.py", _DEFS, std) == []
|
||||
|
||||
|
||||
def test_rule_level_from_standard_is_respected() -> None:
|
||||
std = ConventionsStandard(
|
||||
modules=[Module(path="app/routers", purpose="routes", forbidden=["model"])],
|
||||
rules={"no_models_in_routers": Rule(name="no_models_in_routers", level="warn")},
|
||||
)
|
||||
findings = check_placement("app/routers/users.py", _DEFS, std)
|
||||
assert findings[0].level == "warn"
|
||||
|
||||
|
||||
def test_longest_matching_module_wins() -> None:
|
||||
std = ConventionsStandard(
|
||||
modules=[
|
||||
Module(path="app", purpose="root", forbidden=[]),
|
||||
Module(path="app/routers", purpose="routes", forbidden=["model"]),
|
||||
]
|
||||
)
|
||||
findings = check_placement("app/routers/users.py", _DEFS, std)
|
||||
assert len(findings) == 1
|
||||
assert findings[0].kind == "model"
|
||||
|
||||
|
||||
def test_prefix_must_be_on_a_path_boundary() -> None:
|
||||
# "app/routers" must not match "app/routers_legacy/..." spuriously.
|
||||
std = ConventionsStandard(
|
||||
modules=[Module(path="app/routers", purpose="routes", forbidden=["model"])]
|
||||
)
|
||||
assert check_placement("app/routers_legacy/users.py", _DEFS, std) == []
|
||||
|
||||
|
||||
def test_finding_serializes_to_json_line() -> None:
|
||||
std = ConventionsStandard(
|
||||
modules=[Module(path="app/routers", purpose="routes", forbidden=["model"])]
|
||||
)
|
||||
f = check_placement("app/routers/users.py", _DEFS, std)[0]
|
||||
payload = json.loads(f.as_json())
|
||||
assert payload["rule"] == "no_models_in_routers"
|
||||
assert payload["file"] == "app/routers/users.py"
|
||||
assert payload["line"] == _MODEL_LINE
|
||||
assert payload["level"] == "block"
|
||||
assert set(payload) == {
|
||||
"file",
|
||||
"line",
|
||||
"kind",
|
||||
"rule",
|
||||
"level",
|
||||
"message",
|
||||
"fix_hint",
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Runner: per-file dispatch, waiver filtering, fail-loud on grammar failure."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from roboco.conventions.grammars import GrammarUnavailable
|
||||
from roboco.conventions.runner import ValidatorCouldNotRun, run
|
||||
from roboco.foundation.policy.conventions.models import (
|
||||
ConventionsStandard,
|
||||
Module,
|
||||
Waiver,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_MODEL_PY = b"from pydantic import BaseModel\nclass M(BaseModel):\n x: int\n"
|
||||
|
||||
|
||||
def _write(root: Path, rel: str, content: bytes) -> None:
|
||||
path = root / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
|
||||
|
||||
def test_runner_flags_python_model_in_router(tmp_path: Path) -> None:
|
||||
_write(tmp_path, "app/routers/users.py", _MODEL_PY)
|
||||
std = ConventionsStandard(
|
||||
modules=[Module(path="app/routers", purpose="r", forbidden=["model"])]
|
||||
)
|
||||
findings = run(tmp_path, ["app/routers/users.py"], std)
|
||||
assert [f.rule for f in findings] == ["no_models_in_routers"]
|
||||
|
||||
|
||||
def test_runner_drops_waived_finding(tmp_path: Path) -> None:
|
||||
_write(tmp_path, "app/routers/legacy.py", _MODEL_PY)
|
||||
std = ConventionsStandard(
|
||||
modules=[Module(path="app/routers", purpose="r", forbidden=["model"])],
|
||||
waivers=[
|
||||
Waiver(
|
||||
path="app/routers/legacy.py", rule="no_models_in_routers", reason="x"
|
||||
)
|
||||
],
|
||||
)
|
||||
assert run(tmp_path, ["app/routers/legacy.py"], std) == []
|
||||
|
||||
|
||||
def test_runner_flags_ts_component_in_wrong_module(tmp_path: Path) -> None:
|
||||
_write(tmp_path, "src/pages/Home.tsx", b"export const Home = () => <div/>;\n")
|
||||
std = ConventionsStandard(
|
||||
modules=[Module(path="src/pages", purpose="pages", forbidden=["component"])]
|
||||
)
|
||||
findings = run(tmp_path, ["src/pages/Home.tsx"], std)
|
||||
assert any(f.kind == "component" for f in findings)
|
||||
|
||||
|
||||
def test_runner_skips_unsupported_extension(tmp_path: Path) -> None:
|
||||
_write(tmp_path, "README.md", b"# hi\n")
|
||||
assert run(tmp_path, ["README.md"], ConventionsStandard()) == []
|
||||
|
||||
|
||||
def test_runner_skips_missing_file(tmp_path: Path) -> None:
|
||||
assert run(tmp_path, ["gone.py"], ConventionsStandard()) == []
|
||||
|
||||
|
||||
def test_runner_is_fail_loud_on_grammar_failure(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_write(tmp_path, "x.py", b"x = 1\n")
|
||||
|
||||
def boom(_source: bytes) -> list:
|
||||
raise GrammarUnavailable("python")
|
||||
|
||||
monkeypatch.setattr("roboco.conventions.classify_python.classify_definitions", boom)
|
||||
with pytest.raises(ValidatorCouldNotRun):
|
||||
run(tmp_path, ["x.py"], ConventionsStandard())
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Repo auto-scan + scaffold-draft renderer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from roboco.conventions.scan import derive_from_scan, render_yaml
|
||||
from roboco.foundation.policy.conventions.models import ConventionsStandard
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _sample_repo(root: Path) -> None:
|
||||
(root / "app" / "routers").mkdir(parents=True)
|
||||
(root / "app" / "models").mkdir(parents=True)
|
||||
(root / "app" / "services").mkdir(parents=True)
|
||||
(root / "app" / "routers" / "users.py").write_text("x = 1\n")
|
||||
(root / "app" / "models" / "user.py").write_text("y = 2\n")
|
||||
(root / "app" / "services" / "logic.py").write_text("z = 3\n")
|
||||
|
||||
|
||||
def test_scan_derives_router_module_forbidding_models(tmp_path: Path) -> None:
|
||||
_sample_repo(tmp_path)
|
||||
std = derive_from_scan(tmp_path)
|
||||
routers = [m for m in std.modules if m.path == "app/routers"]
|
||||
assert routers and "model" in routers[0].forbidden
|
||||
|
||||
|
||||
def test_scan_detects_python_language(tmp_path: Path) -> None:
|
||||
_sample_repo(tmp_path)
|
||||
assert "python" in derive_from_scan(tmp_path).languages
|
||||
|
||||
|
||||
def test_scan_seeds_builtin_rules(tmp_path: Path) -> None:
|
||||
_sample_repo(tmp_path)
|
||||
std = derive_from_scan(tmp_path)
|
||||
assert std.rules["no_models_in_routers"].level == "block"
|
||||
assert std.rules["no_inline_comments"].level == "warn"
|
||||
|
||||
|
||||
def test_scan_ignores_vendored_directories(tmp_path: Path) -> None:
|
||||
(tmp_path / "node_modules" / "pkg" / "routers").mkdir(parents=True)
|
||||
(tmp_path / ".venv" / "lib" / "models").mkdir(parents=True)
|
||||
std = derive_from_scan(tmp_path)
|
||||
assert std.modules == []
|
||||
|
||||
|
||||
def test_scan_lifts_claude_md_imperative_into_custom_rule(tmp_path: Path) -> None:
|
||||
_sample_repo(tmp_path)
|
||||
(tmp_path / "CLAUDE.md").write_text("- Never use `print()`; use the logger.\n")
|
||||
custom = derive_from_scan(tmp_path).custom
|
||||
assert custom
|
||||
assert custom[0].level == "warn"
|
||||
assert "print" in custom[0].pattern
|
||||
|
||||
|
||||
def test_render_yaml_round_trips_through_parse(tmp_path: Path) -> None:
|
||||
_sample_repo(tmp_path)
|
||||
(tmp_path / "CLAUDE.md").write_text("Do not call `eval()` anywhere.\n")
|
||||
std = derive_from_scan(tmp_path)
|
||||
reparsed = ConventionsStandard.parse_yaml(render_yaml(std))
|
||||
assert reparsed == std
|
||||
|
||||
|
||||
def test_render_yaml_round_trips_empty_standard() -> None:
|
||||
std = ConventionsStandard()
|
||||
assert ConventionsStandard.parse_yaml(render_yaml(std)) == std
|
||||
Reference in New Issue
Block a user