feat(KIN-010): implement rebuild-frontend post-pipeline hook

- scripts/rebuild-frontend.sh: builds Vue 3 frontend and restarts uvicorn API
- cli/main.py: hook group with add/list/remove/logs/setup commands;
  `hook setup` idempotently registers rebuild-frontend for a project
- agents/runner.py: call run_hooks(event="pipeline_completed") after
  successful pipeline; wrap in try/except so hook errors never block results
- tests: 3 tests for hook_setup CLI + 3 tests for pipeline→hooks integration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gros Frumos 2026-03-15 19:17:42 +02:00
parent 6705b302f7
commit 01b269e2b8
6 changed files with 355 additions and 2 deletions

View file

@ -321,3 +321,34 @@ def test_hook_logs_empty(runner):
r = invoke(runner, ["hook", "logs", "--project", "p1"])
assert r.exit_code == 0
assert "No hook logs" in r.output
def test_hook_setup_registers_rebuild_frontend(runner, tmp_path):
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
r = invoke(runner, ["hook", "setup", "--project", "p1",
"--scripts-dir", str(tmp_path)])
assert r.exit_code == 0
assert "rebuild-frontend" in r.output
r = invoke(runner, ["hook", "list", "--project", "p1"])
assert r.exit_code == 0
assert "rebuild-frontend" in r.output
assert "web/frontend/*" in r.output
def test_hook_setup_idempotent(runner, tmp_path):
invoke(runner, ["project", "add", "p1", "P1", "/p1"])
invoke(runner, ["hook", "setup", "--project", "p1", "--scripts-dir", str(tmp_path)])
r = invoke(runner, ["hook", "setup", "--project", "p1", "--scripts-dir", str(tmp_path)])
assert r.exit_code == 0
assert "already exists" in r.output
r = invoke(runner, ["hook", "list", "--project", "p1"])
# Only one hook, not duplicated
assert r.output.count("rebuild-frontend") == 1
def test_hook_setup_project_not_found(runner):
r = invoke(runner, ["hook", "setup", "--project", "nope"])
assert r.exit_code == 1
assert "not found" in r.output

View file

@ -0,0 +1,112 @@
"""Regression test — KIN-009.
Проверяет, что в рабочей директории проекта НЕ создаются файлы с именами,
содержащими 'sqlite3.Connection'. Такие артефакты появляются, если путь к БД
формируется передачей объекта sqlite3.Connection вместо строки/Path в
sqlite3.connect().
"""
import os
import sqlite3
from pathlib import Path
import pytest
# Корень проекта — три уровня вверх от этого файла (tests/ → kin/)
PROJECT_ROOT = Path(__file__).parent.parent
def _find_connection_artifacts(search_dir: Path) -> list[Path]:
"""Рекурсивно ищет файлы, чьё имя содержит 'sqlite3.Connection'."""
found = []
try:
for entry in search_dir.rglob("*"):
if entry.is_file() and "sqlite3.Connection" in entry.name:
found.append(entry)
except PermissionError:
pass
return found
# ---------------------------------------------------------------------------
# Тест 1: статическая проверка — артефактов нет прямо сейчас
# ---------------------------------------------------------------------------
def test_no_connection_artifact_files_in_project():
"""В рабочей директории проекта не должно быть файлов с 'sqlite3.Connection' в имени."""
artifacts = _find_connection_artifacts(PROJECT_ROOT)
assert artifacts == [], (
f"Найдены файлы-артефакты sqlite3.Connection:\n"
+ "\n".join(f" {p}" for p in artifacts)
)
def test_no_connection_artifact_files_in_kin_home():
"""В ~/.kin/ тоже не должно быть таких файлов."""
kin_home = Path.home() / ".kin"
if not kin_home.exists():
pytest.skip("~/.kin не существует")
artifacts = _find_connection_artifacts(kin_home)
assert artifacts == [], (
f"Найдены файлы-артефакты sqlite3.Connection в ~/.kin:\n"
+ "\n".join(f" {p}" for p in artifacts)
)
# ---------------------------------------------------------------------------
# Тест 2: динамическая проверка — init_db не создаёт артефактов в tmp_path
# ---------------------------------------------------------------------------
def test_init_db_does_not_create_connection_artifact(tmp_path):
"""init_db() должен создавать файл с нормальным именем, а не 'sqlite3.Connection ...'."""
from core.db import init_db
db_file = tmp_path / "test.db"
conn = init_db(db_file)
conn.close()
artifacts = _find_connection_artifacts(tmp_path)
assert artifacts == [], (
"init_db() создал файл с именем, содержащим 'sqlite3.Connection':\n"
+ "\n".join(f" {p}" for p in artifacts)
)
# Убедимся, что файл БД реально создан с правильным именем
assert db_file.exists(), "Файл БД должен существовать"
# ---------------------------------------------------------------------------
# Тест 3: воспроизводит сценарий утечки — connect(conn) вместо connect(path)
# ---------------------------------------------------------------------------
def test_init_db_passes_string_to_sqlite_connect(tmp_path, monkeypatch):
"""core/db.init_db() должен вызывать sqlite3.connect() со строкой пути, а НЕ с объектом Connection.
Баг-сценарий: если где-то в коде путь к БД перепутан с объектом conn,
sqlite3.connect(str(conn)) создаст файл с именем '<sqlite3.Connection object at 0x...>'.
Этот тест перехватывает вызов и проверяет тип аргумента напрямую.
"""
import core.db as db_module
connect_calls: list = []
real_connect = sqlite3.connect
def mock_connect(database, *args, **kwargs):
connect_calls.append(database)
return real_connect(database, *args, **kwargs)
monkeypatch.setattr(db_module.sqlite3, "connect", mock_connect)
db_file = tmp_path / "test.db"
conn = db_module.init_db(db_file)
conn.close()
assert connect_calls, "sqlite3.connect() должен быть вызван хотя бы один раз"
for call_arg in connect_calls:
assert isinstance(call_arg, str), (
f"sqlite3.connect() получил не строку: {type(call_arg).__name__!r} = {call_arg!r}"
)
assert "sqlite3.Connection" not in call_arg, (
f"sqlite3.connect() получил строку объекта Connection: {call_arg!r}\n"
"Баг: str(conn) передаётся вместо пути к файлу — это создаёт файл-артефакт!"
)

View file

@ -249,6 +249,45 @@ class TestRunPipeline:
assert result["success"] is False
assert "not found" in result["error"]
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_hooks_called_after_successful_pipeline(self, mock_run, mock_hooks, conn):
mock_run.return_value = _mock_claude_success({"result": "done"})
mock_hooks.return_value = []
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
mock_hooks.assert_called_once()
call_kwargs = mock_hooks.call_args
assert call_kwargs[1].get("event") == "pipeline_completed" or \
call_kwargs[0][3] == "pipeline_completed"
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_hooks_not_called_on_failed_pipeline(self, mock_run, mock_hooks, conn):
mock_run.return_value = _mock_claude_failure("compilation error")
mock_hooks.return_value = []
steps = [{"role": "debugger", "brief": "find"}]
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is False
mock_hooks.assert_not_called()
@patch("agents.runner.run_hooks")
@patch("agents.runner.subprocess.run")
def test_hook_failure_does_not_affect_pipeline_result(self, mock_run, mock_hooks, conn):
mock_run.return_value = _mock_claude_success({"result": "done"})
mock_hooks.side_effect = Exception("hook exploded")
steps = [{"role": "debugger", "brief": "find"}]
# Must not raise — hook failures must not propagate
result = run_pipeline(conn, "VDOL-001", steps)
assert result["success"] is True
# ---------------------------------------------------------------------------
# JSON parsing