feat(ralph): smart gates + DAG + dashboard live (W3)
Restructurare Ralph QC loop pe smart gate dispatcher tag-driven (în loc de 5 faze fixe), DAG dependsOn cu propagare blocked, retry guard 3-strike, rate limit detection, plus dashboard live cu polling 5s. Changes: - tools/ralph_prd_generator.py: parametru optional final_plan_path; când e furnizat, invocă Claude Opus pe final-plan.md pentru extragere user stories cu schema extinsă (tags, dependsOn, acceptanceCriteria 3-5). Backward compat păstrat — fără final_plan_path, fallback la heuristic-ul vechi. - tools/ralph/prd-template.json: schema W3 (tags[], dependsOn[], retries, failed, blocked, failureReason, requiresDesignReview). - tools/ralph/prompt.md: 4 faze (impl, base quality, smart gates, commit) + dispatcher pe story.tags. Tags vide → run-all-gates fallback (safe default). - tools/ralph_dag.py (nou): tag validation heuristic anti-silent-regression (force ui dacă diff atinge .vue/.tsx/.html/.css/.scss; force db pentru migrations sau .sql; force vercel dacă există vercel.json) + topological sort cu blocked propagation + atomic prd.json updates. - tools/ralph/ralph.sh: --max-turns 30, DAG-aware story selection, retry counter cu auto-fail la 3, rate limit detection (sleep 30min + 1 retry), CLI subcommands prin tools/ralph_dag.py helper. - dashboard/handlers/ralph.py (nou): /api/ralph/status + /<slug>/log + /prd + /stop. Defensive vs corrupt prd.json. Sandbox-ed PID kill. - dashboard/ralph.html (nou): live cards 3/2/1 col responsive, polling 5s, drawer pentru log/PRD viewer, status colors (--status-running/blocked/ failed/complete declarate inline), Lucide icons cu aria-labels. - dashboard/api.py: mount /api/ralph/* (GET status/log/prd, POST stop). - tests/: 72 teste noi (smart gates, DAG, retry, dashboard endpoint). Note arhitecturale: - Polling 5s ales peste SSE/WebSocket (suficient pentru iter Ralph 8-15min) - Tag validation rulează POST-iter pe diff git pentru anti-silent-regression - Rate limit retry: 1 dată per rulare, apoi mark failed=rate_limited Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
250
tests/test_dag_execution.py
Normal file
250
tests/test_dag_execution.py
Normal file
@@ -0,0 +1,250 @@
|
||||
"""Tests for W3 DAG execution + retry guard.
|
||||
|
||||
Acoperă:
|
||||
- topological_eligible: alegere story DAG-aware (passes/failed/blocked propagation)
|
||||
- cmd_incr_retry: 3-retry guard cu auto-fail la max_retries
|
||||
- cmd_mark_failed: propagare blocked la dependenți
|
||||
- _normalize_story: validează schema extinsă (tags, dependsOn, retries, blocked)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from tools import ralph_dag # noqa: E402
|
||||
from tools.ralph_prd_generator import _normalize_story # noqa: E402
|
||||
|
||||
|
||||
# ── topological_eligible ───────────────────────────────────────
|
||||
|
||||
|
||||
def _stories(*specs):
|
||||
"""Helper: build minimal stories list. Each spec = (id, priority, dependsOn, flags...)"""
|
||||
out = []
|
||||
for spec in specs:
|
||||
sid, prio, deps, *rest = spec
|
||||
s = {
|
||||
"id": sid,
|
||||
"priority": prio,
|
||||
"dependsOn": list(deps),
|
||||
"passes": False,
|
||||
"failed": False,
|
||||
"blocked": False,
|
||||
"retries": 0,
|
||||
}
|
||||
for flag in rest:
|
||||
s[flag] = True
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
class TestTopologicalEligible:
|
||||
def test_no_deps_lowest_priority_picked(self):
|
||||
stories = _stories(
|
||||
("US-002", 20, []),
|
||||
("US-001", 10, []),
|
||||
)
|
||||
chosen = ralph_dag.topological_eligible(stories)
|
||||
assert chosen["id"] == "US-001"
|
||||
|
||||
def test_dependent_skipped_until_dep_passes(self):
|
||||
stories = _stories(
|
||||
("US-001", 10, []),
|
||||
("US-002", 20, ["US-001"]),
|
||||
)
|
||||
# US-001 not done yet → US-001 picked
|
||||
assert ralph_dag.topological_eligible(stories)["id"] == "US-001"
|
||||
|
||||
# Mark US-001 passes → US-002 eligible
|
||||
stories[0]["passes"] = True
|
||||
assert ralph_dag.topological_eligible(stories)["id"] == "US-002"
|
||||
|
||||
def test_failed_dep_propagates_blocked(self):
|
||||
stories = _stories(
|
||||
("US-001", 10, []),
|
||||
("US-002", 20, ["US-001"]),
|
||||
)
|
||||
stories[0]["failed"] = True
|
||||
chosen = ralph_dag.topological_eligible(stories)
|
||||
assert chosen is None # US-002 marcat blocked, nimic eligibil
|
||||
assert stories[1]["blocked"] is True
|
||||
assert stories[1]["failureReason"] == "blocked_by:US-001"
|
||||
|
||||
def test_independent_runs_when_other_chain_failed(self):
|
||||
# US-001 failed → US-002 blocked, dar US-003 e independent → eligibil
|
||||
stories = _stories(
|
||||
("US-001", 10, []),
|
||||
("US-002", 20, ["US-001"]),
|
||||
("US-003", 30, []),
|
||||
)
|
||||
stories[0]["failed"] = True
|
||||
chosen = ralph_dag.topological_eligible(stories)
|
||||
assert chosen["id"] == "US-003"
|
||||
assert stories[1]["blocked"] is True
|
||||
|
||||
def test_chain_blocking_propagates_transitively(self):
|
||||
# US-001 → US-002 → US-003. US-001 failed → US-002 blocked → US-003 blocked.
|
||||
stories = _stories(
|
||||
("US-001", 10, []),
|
||||
("US-002", 20, ["US-001"]),
|
||||
("US-003", 30, ["US-002"]),
|
||||
)
|
||||
stories[0]["failed"] = True
|
||||
chosen = ralph_dag.topological_eligible(stories)
|
||||
assert chosen is None
|
||||
assert stories[1]["blocked"] is True
|
||||
assert stories[2]["blocked"] is True
|
||||
|
||||
def test_all_complete_returns_none(self):
|
||||
stories = _stories(("US-001", 10, []))
|
||||
stories[0]["passes"] = True
|
||||
assert ralph_dag.topological_eligible(stories) is None
|
||||
|
||||
def test_already_blocked_story_skipped(self):
|
||||
stories = _stories(
|
||||
("US-001", 10, []),
|
||||
("US-002", 20, []),
|
||||
)
|
||||
stories[0]["blocked"] = True
|
||||
chosen = ralph_dag.topological_eligible(stories)
|
||||
assert chosen["id"] == "US-002"
|
||||
|
||||
|
||||
# ── cmd_incr_retry / cmd_mark_failed (file-based) ──────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def prd_path(tmp_path):
|
||||
"""Construiește un prd.json minimal pentru test."""
|
||||
data = {
|
||||
"projectName": "test-proj",
|
||||
"branchName": "ralph/test-proj",
|
||||
"userStories": [
|
||||
{
|
||||
"id": "US-001", "title": "first", "priority": 10,
|
||||
"dependsOn": [], "tags": [], "acceptanceCriteria": ["a"],
|
||||
"passes": False, "failed": False, "blocked": False, "retries": 0,
|
||||
},
|
||||
{
|
||||
"id": "US-002", "title": "second", "priority": 20,
|
||||
"dependsOn": ["US-001"], "tags": [], "acceptanceCriteria": ["a"],
|
||||
"passes": False, "failed": False, "blocked": False, "retries": 0,
|
||||
},
|
||||
],
|
||||
}
|
||||
p = tmp_path / "prd.json"
|
||||
p.write_text(json.dumps(data), encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
class TestRetryGuard:
|
||||
def test_incr_retry_increments_count(self, prd_path):
|
||||
rc = ralph_dag.cmd_incr_retry(prd_path, "US-001")
|
||||
assert rc == 0
|
||||
data = json.loads(prd_path.read_text())
|
||||
assert data["userStories"][0]["retries"] == 1
|
||||
assert data["userStories"][0]["failed"] is False
|
||||
|
||||
def test_three_retries_marks_failed_max_retries(self, prd_path):
|
||||
# incr 3 times
|
||||
for _ in range(3):
|
||||
ralph_dag.cmd_incr_retry(prd_path, "US-001")
|
||||
data = json.loads(prd_path.read_text())
|
||||
assert data["userStories"][0]["retries"] == 3
|
||||
assert data["userStories"][0]["failed"] is True
|
||||
assert data["userStories"][0]["failureReason"] == "max_retries"
|
||||
|
||||
def test_max_retries_propagates_blocked_to_dependent(self, prd_path):
|
||||
for _ in range(3):
|
||||
ralph_dag.cmd_incr_retry(prd_path, "US-001")
|
||||
data = json.loads(prd_path.read_text())
|
||||
# US-002 depinde de US-001 → blocked
|
||||
assert data["userStories"][1]["blocked"] is True
|
||||
|
||||
def test_unknown_story_returns_error(self, prd_path):
|
||||
rc = ralph_dag.cmd_incr_retry(prd_path, "US-999")
|
||||
assert rc == 1
|
||||
|
||||
|
||||
class TestMarkFailed:
|
||||
def test_mark_failed_sets_flags(self, prd_path):
|
||||
rc = ralph_dag.cmd_mark_failed(prd_path, "US-001", "rate_limited")
|
||||
assert rc == 0
|
||||
data = json.loads(prd_path.read_text())
|
||||
assert data["userStories"][0]["failed"] is True
|
||||
assert data["userStories"][0]["failureReason"] == "rate_limited"
|
||||
|
||||
def test_mark_failed_propagates_blocked(self, prd_path):
|
||||
ralph_dag.cmd_mark_failed(prd_path, "US-001", "rate_limited")
|
||||
data = json.loads(prd_path.read_text())
|
||||
assert data["userStories"][1]["blocked"] is True
|
||||
assert data["userStories"][1]["failureReason"] == "blocked_by:US-001"
|
||||
|
||||
|
||||
class TestNextStory:
|
||||
def test_next_story_prints_id(self, prd_path, capsys):
|
||||
rc = ralph_dag.cmd_next_story(prd_path)
|
||||
assert rc == 0
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out.strip() == "US-001"
|
||||
|
||||
def test_next_story_returns_1_when_none_eligible(self, prd_path, capsys):
|
||||
# Mark all complete
|
||||
data = json.loads(prd_path.read_text())
|
||||
for s in data["userStories"]:
|
||||
s["passes"] = True
|
||||
prd_path.write_text(json.dumps(data))
|
||||
rc = ralph_dag.cmd_next_story(prd_path)
|
||||
assert rc == 1
|
||||
|
||||
|
||||
# ── _normalize_story (PRD generator schema) ────────────────────
|
||||
|
||||
|
||||
class TestNormalizeStory:
|
||||
def test_default_fields_populated(self):
|
||||
s = _normalize_story({"title": "x"}, idx=0)
|
||||
# Schema W3 fields trebuie să existe toate
|
||||
for key in ("id", "title", "description", "priority", "acceptanceCriteria",
|
||||
"tags", "dependsOn", "passes", "failed", "blocked", "retries",
|
||||
"failureReason", "notes"):
|
||||
assert key in s, f"Missing schema field: {key}"
|
||||
assert s["passes"] is False
|
||||
assert s["failed"] is False
|
||||
assert s["blocked"] is False
|
||||
assert s["retries"] == 0
|
||||
|
||||
def test_invalid_tags_filtered(self):
|
||||
s = _normalize_story({"title": "x", "tags": ["frontend", "ui", "made-up"]}, idx=0)
|
||||
assert s["tags"] == ["ui"] # frontend & made-up nu sunt în VALID_TAGS
|
||||
|
||||
def test_empty_acceptance_gets_default(self):
|
||||
s = _normalize_story({"title": "x"}, idx=0)
|
||||
assert len(s["acceptanceCriteria"]) >= 1
|
||||
|
||||
def test_ui_tag_implies_browser_check(self):
|
||||
s = _normalize_story({"title": "x", "tags": ["ui"]}, idx=0)
|
||||
assert s["requiresBrowserCheck"] is True
|
||||
|
||||
def test_explicit_browser_check_preserved(self):
|
||||
s = _normalize_story({"title": "x", "tags": [], "requiresBrowserCheck": True}, idx=0)
|
||||
assert s["requiresBrowserCheck"] is True
|
||||
|
||||
def test_id_auto_generated_from_idx(self):
|
||||
s = _normalize_story({"title": "x"}, idx=4)
|
||||
assert s["id"] == "US-005"
|
||||
|
||||
def test_id_preserved_when_provided(self):
|
||||
s = _normalize_story({"id": "US-042", "title": "x"}, idx=0)
|
||||
assert s["id"] == "US-042"
|
||||
|
||||
def test_depends_on_preserves_strings_filters_garbage(self):
|
||||
s = _normalize_story({"title": "x", "dependsOn": ["US-001", "", None, "US-002"]}, idx=0)
|
||||
assert s["dependsOn"] == ["US-001", "US-002"]
|
||||
209
tests/test_dashboard_ralph_endpoint.py
Normal file
209
tests/test_dashboard_ralph_endpoint.py
Normal file
@@ -0,0 +1,209 @@
|
||||
"""Tests for /api/ralph/* endpoints (dashboard live).
|
||||
|
||||
Acoperă:
|
||||
- /api/ralph/status: list cards cu state + count + fetchedAt
|
||||
- /api/ralph/<slug>/log: tail progress.txt
|
||||
- /api/ralph/<slug>/prd: full prd.json
|
||||
- _ralph_validate_slug: path traversal protection
|
||||
- corrupt prd.json: graceful error (status='error', not 500)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
DASH = PROJECT_ROOT / "dashboard"
|
||||
if str(DASH) not in sys.path:
|
||||
sys.path.insert(0, str(DASH))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ralph_module():
|
||||
from handlers import ralph as _r # type: ignore
|
||||
return _r
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def handler(ralph_module, tmp_path, monkeypatch):
|
||||
"""Build a stubbed handler with a temp WORKSPACE_DIR."""
|
||||
import constants # type: ignore
|
||||
|
||||
# Re-route WORKSPACE_DIR la tmp pentru izolare
|
||||
monkeypatch.setattr(constants, "WORKSPACE_DIR", tmp_path)
|
||||
|
||||
class _Stub(ralph_module.RalphHandlers):
|
||||
def __init__(self):
|
||||
self.captured = None
|
||||
self.captured_code = None
|
||||
self.path = "/api/ralph/status"
|
||||
|
||||
def send_json(self, data, code=200):
|
||||
self.captured = data
|
||||
self.captured_code = code
|
||||
|
||||
def send_error(self, code):
|
||||
self.captured = {"error_code": code}
|
||||
self.captured_code = code
|
||||
|
||||
return _Stub()
|
||||
|
||||
|
||||
def _make_ralph_project(workspace: Path, slug: str, stories: list, progress: str = "init"):
|
||||
"""Create a fake ralph project under workspace/<slug>/scripts/ralph/."""
|
||||
ralph_dir = workspace / slug / "scripts" / "ralph"
|
||||
ralph_dir.mkdir(parents=True, exist_ok=True)
|
||||
(ralph_dir / "prd.json").write_text(json.dumps({
|
||||
"projectName": slug,
|
||||
"branchName": f"ralph/{slug}",
|
||||
"userStories": stories,
|
||||
}), encoding="utf-8")
|
||||
(ralph_dir / "progress.txt").write_text(progress, encoding="utf-8")
|
||||
return ralph_dir
|
||||
|
||||
|
||||
# ── /api/ralph/status ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestStatus:
|
||||
def test_empty_workspace_returns_empty(self, handler):
|
||||
handler.handle_ralph_status()
|
||||
assert handler.captured_code == 200
|
||||
assert handler.captured["projects"] == []
|
||||
assert "fetchedAt" in handler.captured
|
||||
|
||||
def test_status_skips_non_ralph_projects(self, handler, tmp_path):
|
||||
# Create a project WITHOUT scripts/ralph
|
||||
(tmp_path / "regular-proj").mkdir()
|
||||
handler.handle_ralph_status()
|
||||
assert handler.captured["projects"] == []
|
||||
|
||||
def test_status_lists_ralph_projects(self, handler, tmp_path):
|
||||
_make_ralph_project(tmp_path, "proj-a", [
|
||||
{"id": "US-001", "title": "a", "priority": 10, "passes": True,
|
||||
"failed": False, "blocked": False, "retries": 0, "tags": []},
|
||||
{"id": "US-002", "title": "b", "priority": 20, "passes": False,
|
||||
"failed": False, "blocked": False, "retries": 0, "tags": ["ui"]},
|
||||
])
|
||||
handler.handle_ralph_status()
|
||||
projects = handler.captured["projects"]
|
||||
assert len(projects) == 1
|
||||
p = projects[0]
|
||||
assert p["slug"] == "proj-a"
|
||||
assert p["storiesTotal"] == 2
|
||||
assert p["storiesComplete"] == 1
|
||||
assert p["storiesFailed"] == 0
|
||||
assert p["storiesBlocked"] == 0
|
||||
assert p["status"] == "idle" # not running (no .ralph.pid)
|
||||
|
||||
def test_status_corrupt_prd_returns_error_not_500(self, handler, tmp_path):
|
||||
ralph_dir = tmp_path / "broken" / "scripts" / "ralph"
|
||||
ralph_dir.mkdir(parents=True)
|
||||
(ralph_dir / "prd.json").write_text("{not valid json", encoding="utf-8")
|
||||
handler.handle_ralph_status()
|
||||
assert handler.captured_code == 200
|
||||
assert any(p.get("status") == "error" for p in handler.captured["projects"])
|
||||
|
||||
def test_status_count_matches_projects(self, handler, tmp_path):
|
||||
_make_ralph_project(tmp_path, "p1", [])
|
||||
_make_ralph_project(tmp_path, "p2", [])
|
||||
handler.handle_ralph_status()
|
||||
assert handler.captured["count"] == 2
|
||||
|
||||
def test_complete_status_when_all_pass(self, handler, tmp_path):
|
||||
_make_ralph_project(tmp_path, "donezo", [
|
||||
{"id": "US-001", "passes": True, "failed": False, "blocked": False,
|
||||
"retries": 0, "tags": [], "title": "x", "priority": 10},
|
||||
])
|
||||
handler.handle_ralph_status()
|
||||
p = handler.captured["projects"][0]
|
||||
assert p["status"] == "complete"
|
||||
|
||||
def test_failed_status_propagation(self, handler, tmp_path):
|
||||
_make_ralph_project(tmp_path, "broken-proj", [
|
||||
{"id": "US-001", "passes": False, "failed": True, "blocked": False,
|
||||
"retries": 3, "tags": [], "title": "x", "priority": 10,
|
||||
"failureReason": "max_retries"},
|
||||
])
|
||||
handler.handle_ralph_status()
|
||||
p = handler.captured["projects"][0]
|
||||
assert p["status"] == "failed"
|
||||
assert p["storiesFailed"] == 1
|
||||
|
||||
|
||||
# ── /api/ralph/<slug>/log ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestLog:
|
||||
def test_log_returns_progress_lines(self, handler, tmp_path):
|
||||
_make_ralph_project(tmp_path, "p1", [], progress="line1\nline2\nline3")
|
||||
handler.path = "/api/ralph/p1/log"
|
||||
handler.handle_ralph_log("p1")
|
||||
assert handler.captured_code == 200
|
||||
assert handler.captured["lines"] == ["line1", "line2", "line3"]
|
||||
assert handler.captured["total"] == 3
|
||||
|
||||
def test_log_invalid_slug_400(self, handler):
|
||||
handler.handle_ralph_log("../etc/passwd")
|
||||
assert handler.captured_code == 400
|
||||
|
||||
def test_log_path_traversal_blocked(self, handler):
|
||||
handler.handle_ralph_log("..")
|
||||
assert handler.captured_code == 400
|
||||
|
||||
def test_log_missing_progress_returns_empty(self, handler, tmp_path):
|
||||
ralph_dir = tmp_path / "noprogress" / "scripts" / "ralph"
|
||||
ralph_dir.mkdir(parents=True)
|
||||
(ralph_dir / "prd.json").write_text("{}") # no progress.txt
|
||||
handler.path = "/api/ralph/noprogress/log"
|
||||
handler.handle_ralph_log("noprogress")
|
||||
assert handler.captured_code == 200
|
||||
assert handler.captured["lines"] == []
|
||||
|
||||
|
||||
# ── /api/ralph/<slug>/prd ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestPrd:
|
||||
def test_prd_returns_full_json(self, handler, tmp_path):
|
||||
stories = [{"id": "US-001", "passes": False, "title": "t", "priority": 10}]
|
||||
_make_ralph_project(tmp_path, "p1", stories)
|
||||
handler.handle_ralph_prd("p1")
|
||||
assert handler.captured_code == 200
|
||||
assert handler.captured["projectName"] == "p1"
|
||||
assert len(handler.captured["userStories"]) == 1
|
||||
|
||||
def test_prd_404_when_missing(self, handler, tmp_path):
|
||||
(tmp_path / "ghost").mkdir() # exists, but no prd.json
|
||||
handler.handle_ralph_prd("ghost")
|
||||
assert handler.captured_code == 404
|
||||
|
||||
def test_prd_invalid_slug_400(self, handler):
|
||||
handler.handle_ralph_prd("/etc/passwd")
|
||||
assert handler.captured_code == 400
|
||||
|
||||
|
||||
# ── _ralph_validate_slug ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateSlug:
|
||||
def test_valid_slug_returns_path(self, handler, tmp_path):
|
||||
(tmp_path / "good-slug").mkdir()
|
||||
result = handler._ralph_validate_slug("good-slug")
|
||||
assert result is not None
|
||||
assert result.name == "good-slug"
|
||||
|
||||
def test_slash_rejected(self, handler):
|
||||
assert handler._ralph_validate_slug("a/b") is None
|
||||
|
||||
def test_dotdot_rejected(self, handler):
|
||||
assert handler._ralph_validate_slug("..") is None
|
||||
|
||||
def test_empty_rejected(self, handler):
|
||||
assert handler._ralph_validate_slug("") is None
|
||||
|
||||
def test_nonexistent_returns_none(self, handler):
|
||||
assert handler._ralph_validate_slug("does-not-exist") is None
|
||||
174
tests/test_smart_gates.py
Normal file
174
tests/test_smart_gates.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""Tests for W3 smart gates tag validation heuristic.
|
||||
|
||||
Acoperă:
|
||||
- infer_tags_from_paths: detect ui/db/vercel pe baza file extensions / paths
|
||||
- force_include_tags: combinare tags Opus + tags inferate din diff (anti-silent-regression)
|
||||
- Toate combinatii de tag types (ui, db, vercel, refactor, docs, backend, infra)
|
||||
- Edge cases: tags vide, tags invalide, empty diff
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from tools.ralph_dag import ( # noqa: E402
|
||||
VALID_TAGS,
|
||||
force_include_tags,
|
||||
infer_tags_from_paths,
|
||||
)
|
||||
|
||||
|
||||
# ── infer_tags_from_paths ──────────────────────────────────────
|
||||
|
||||
|
||||
class TestInferTags:
|
||||
def test_empty_diff_no_tags(self):
|
||||
assert infer_tags_from_paths([]) == []
|
||||
|
||||
def test_only_readme_no_tags(self):
|
||||
assert infer_tags_from_paths(["README.md", "CHANGELOG.md"]) == []
|
||||
|
||||
def test_vue_triggers_ui(self):
|
||||
assert infer_tags_from_paths(["src/App.vue"]) == ["ui"]
|
||||
|
||||
def test_tsx_triggers_ui(self):
|
||||
assert infer_tags_from_paths(["app/page.tsx"]) == ["ui"]
|
||||
|
||||
def test_jsx_triggers_ui(self):
|
||||
assert infer_tags_from_paths(["src/Button.jsx"]) == ["ui"]
|
||||
|
||||
def test_html_triggers_ui(self):
|
||||
assert infer_tags_from_paths(["dashboard/index.html"]) == ["ui"]
|
||||
|
||||
def test_css_scss_trigger_ui(self):
|
||||
assert infer_tags_from_paths(["src/main.css"]) == ["ui"]
|
||||
assert infer_tags_from_paths(["src/main.scss"]) == ["ui"]
|
||||
|
||||
def test_svelte_triggers_ui(self):
|
||||
assert infer_tags_from_paths(["src/App.svelte"]) == ["ui"]
|
||||
|
||||
def test_migrations_triggers_db(self):
|
||||
assert infer_tags_from_paths(["db/migrations/0001_init.sql"]) == ["db"]
|
||||
|
||||
def test_top_level_migrations_triggers_db(self):
|
||||
assert infer_tags_from_paths(["migrations/2026/04/add_users.sql"]) == ["db"]
|
||||
|
||||
def test_sql_outside_migrations_still_triggers_db(self):
|
||||
assert infer_tags_from_paths(["scripts/seed.sql"]) == ["db"]
|
||||
|
||||
def test_vercel_json_only(self):
|
||||
assert infer_tags_from_paths([], has_vercel_json=True) == ["vercel"]
|
||||
|
||||
def test_combined_ui_db_vercel(self):
|
||||
result = infer_tags_from_paths(
|
||||
["app/page.tsx", "db/migrations/0001.sql"], has_vercel_json=True
|
||||
)
|
||||
assert result == ["ui", "db", "vercel"]
|
||||
|
||||
def test_dedup_when_multiple_files_same_category(self):
|
||||
result = infer_tags_from_paths(["a.tsx", "b.vue", "c.css"])
|
||||
assert result == ["ui"]
|
||||
|
||||
def test_case_insensitive_extensions(self):
|
||||
assert infer_tags_from_paths(["src/App.TSX"]) == ["ui"]
|
||||
assert infer_tags_from_paths(["db/Init.SQL"]) == ["db"]
|
||||
|
||||
|
||||
# ── force_include_tags ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestForceIncludeTags:
|
||||
def test_existing_only_no_diff(self):
|
||||
assert force_include_tags(["backend"], [], False) == ["backend"]
|
||||
|
||||
def test_diff_inferred_added_to_existing(self):
|
||||
# Opus marcat docs, dar diff atinge .tsx → ui forțat
|
||||
result = force_include_tags(["docs"], ["src/Page.tsx"], False)
|
||||
assert "docs" in result
|
||||
assert "ui" in result
|
||||
|
||||
def test_filters_invalid_tags_from_existing(self):
|
||||
# Tag-ul "frontend" nu e în VALID_TAGS — trebuie eliminat
|
||||
result = force_include_tags(["frontend", "ui"], [], False)
|
||||
assert "frontend" not in result
|
||||
assert "ui" in result
|
||||
|
||||
def test_empty_when_no_existing_no_diff(self):
|
||||
assert force_include_tags([], [], False) == []
|
||||
|
||||
def test_dedup_existing_and_inferred(self):
|
||||
# Existing are ui, diff are .tsx → un singur ui în output
|
||||
result = force_include_tags(["ui"], ["src/A.tsx"], False)
|
||||
assert result.count("ui") == 1
|
||||
|
||||
def test_vercel_added_when_vercel_json_present(self):
|
||||
result = force_include_tags(["backend"], [], has_vercel_json=True)
|
||||
assert "vercel" in result
|
||||
assert "backend" in result
|
||||
|
||||
def test_all_valid_tags_preserved(self):
|
||||
# Verifică că force_include nu strică tags valide existente
|
||||
all_valid = list(VALID_TAGS)
|
||||
result = force_include_tags(all_valid, [], False)
|
||||
for t in all_valid:
|
||||
assert t in result
|
||||
|
||||
def test_order_existing_first_then_inferred(self):
|
||||
# Existing tags trebuie să apară primele (stabilitate API)
|
||||
result = force_include_tags(["backend"], ["src/Page.tsx", "db/migrations/0001.sql"], False)
|
||||
assert result[0] == "backend"
|
||||
assert "ui" in result and "db" in result
|
||||
|
||||
|
||||
# ── Smart gates dispatcher contract (combinatii tag → expected gates) ─────────
|
||||
|
||||
|
||||
# Acesta e un table-test pentru contractul dispatcher-ului din prompt.md.
|
||||
# Verifică doar mapping-ul tag → gate name (specifice prompt.md), nu execuția.
|
||||
GATE_MAPPING = {
|
||||
"refactor": "/workflow:simplify",
|
||||
"ui": "/qa",
|
||||
"vercel": "gh pr checks",
|
||||
"db": "schema diff",
|
||||
"docs": None, # docs => doar typecheck base
|
||||
"backend": "/review",
|
||||
"infra": "/review",
|
||||
}
|
||||
|
||||
|
||||
class TestGateMapping:
|
||||
"""Validează că prompt.md menționează gate-urile așteptate per tag."""
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def prompt_md(self):
|
||||
path = PROJECT_ROOT / "tools" / "ralph" / "prompt.md"
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
def test_refactor_gate_documented(self, prompt_md):
|
||||
assert "/workflow:simplify" in prompt_md
|
||||
|
||||
def test_ui_gate_documented(self, prompt_md):
|
||||
assert "/qa" in prompt_md
|
||||
assert "agent-browser" in prompt_md.lower()
|
||||
|
||||
def test_vercel_gate_documented(self, prompt_md):
|
||||
assert "gh pr checks" in prompt_md
|
||||
|
||||
def test_db_gate_documented(self, prompt_md):
|
||||
assert "schema diff" in prompt_md.lower() or "alembic" in prompt_md.lower()
|
||||
|
||||
def test_backend_gate_documented(self, prompt_md):
|
||||
assert "/review" in prompt_md
|
||||
|
||||
def test_run_all_fallback_documented(self, prompt_md):
|
||||
# Tags vide → run-all-gates fallback (safe default)
|
||||
assert "tags vide" in prompt_md.lower() or "run-all-gates" in prompt_md.lower()
|
||||
|
||||
def test_dag_dependson_documented(self, prompt_md):
|
||||
assert "dependsOn" in prompt_md or "DAG" in prompt_md
|
||||
Reference in New Issue
Block a user