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,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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,28 @@
|
||||
"""TaskDescription.constraints: renders as a section when present, else absent."""
|
||||
|
||||
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()
|
||||
@@ -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
|
||||
@@ -0,0 +1,111 @@
|
||||
"""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
|
||||
|
||||
|
||||
@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
|
||||
@@ -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
|
||||
@@ -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"] == []
|
||||
@@ -230,7 +230,7 @@ def _wire_spawn_mocks(
|
||||
monkeypatch.setattr(
|
||||
orch,
|
||||
"_generate_composed_prompt",
|
||||
lambda _aid: Path("/tmp/intake-1-prompt.md"),
|
||||
lambda *_args, **_kwargs: Path("/tmp/intake-1-prompt.md"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
orch,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user