"""Tests for agents/bootstrap.py — tech detection, modules, decisions, obsidian."""
import json
import pytest
from pathlib import Path
from click.testing import CliRunner
from agents.bootstrap import (
detect_tech_stack, detect_modules, extract_decisions_from_claude_md,
find_vault_root, scan_obsidian, format_preview, save_to_db,
)
from core.db import init_db
from core import models
from cli.main import cli
# ---------------------------------------------------------------------------
# Tech stack detection
# ---------------------------------------------------------------------------
def test_detect_node_project(tmp_path):
(tmp_path / "package.json").write_text(json.dumps({
"dependencies": {"vue": "^3.4", "pinia": "^2.0"},
"devDependencies": {"typescript": "^5.0", "vite": "^5.0"},
}))
(tmp_path / "tsconfig.json").write_text("{}")
(tmp_path / "nuxt.config.ts").write_text("export default {}")
stack = detect_tech_stack(tmp_path)
assert "vue3" in stack
assert "typescript" in stack
assert "nuxt3" in stack
assert "pinia" in stack
assert "vite" in stack
def test_detect_python_project(tmp_path):
(tmp_path / "requirements.txt").write_text("fastapi==0.104\npydantic>=2.0\n")
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
stack = detect_tech_stack(tmp_path)
assert "python" in stack
assert "fastapi" in stack
assert "pydantic" in stack
def test_detect_go_project(tmp_path):
(tmp_path / "go.mod").write_text("module example.com/foo\nrequire gin-gonic v1.9\n")
stack = detect_tech_stack(tmp_path)
assert "go" in stack
assert "gin" in stack
def test_detect_monorepo(tmp_path):
fe = tmp_path / "frontend"
fe.mkdir()
(fe / "package.json").write_text(json.dumps({
"dependencies": {"vue": "^3.0"},
}))
be = tmp_path / "backend"
be.mkdir()
(be / "requirements.txt").write_text("fastapi\n")
stack = detect_tech_stack(tmp_path)
assert "vue3" in stack
assert "fastapi" in stack
def test_detect_deep_monorepo(tmp_path):
"""Test that files nested 2-3 levels deep are found (like vdolipoperek)."""
fe = tmp_path / "frontend" / "src"
fe.mkdir(parents=True)
(tmp_path / "frontend" / "package.json").write_text(json.dumps({
"dependencies": {"vue": "^3.4"},
"devDependencies": {"vite": "^5.0", "tailwindcss": "^3.4"},
}))
(tmp_path / "frontend" / "vite.config.js").write_text("export default {}")
(tmp_path / "frontend" / "tailwind.config.js").write_text("module.exports = {}")
be = tmp_path / "backend-pg" / "src"
be.mkdir(parents=True)
(be / "index.js").write_text("const express = require('express');")
stack = detect_tech_stack(tmp_path)
assert "vue3" in stack
assert "vite" in stack
assert "tailwind" in stack
def test_detect_empty_dir(tmp_path):
assert detect_tech_stack(tmp_path) == []
# ---------------------------------------------------------------------------
# Module detection
# ---------------------------------------------------------------------------
def test_detect_modules_vue(tmp_path):
src = tmp_path / "src"
(src / "components" / "search").mkdir(parents=True)
(src / "components" / "search" / "Search.vue").write_text("")
(src / "components" / "search" / "SearchFilter.vue").write_text("")
(src / "api" / "auth").mkdir(parents=True)
(src / "api" / "auth" / "login.ts").write_text("import express from 'express';\nconst router = express.Router();")
modules = detect_modules(tmp_path)
names = {m["name"] for m in modules}
assert "components" in names or "search" in names
assert "api" in names or "auth" in names
def test_detect_modules_empty(tmp_path):
assert detect_modules(tmp_path) == []
def test_detect_modules_deduplication_by_name(tmp_path):
"""KIN-081: detect_modules дедуплицирует по имени (не по имени+путь).
Если два разных scan_dir дают одноимённые модули (например, frontend/src/components
и backend/src/components), результат содержит только первый.
Это соответствует UNIQUE constraint (project_id, name) в таблице modules.
"""
fe_comp = tmp_path / "frontend" / "src" / "components"
fe_comp.mkdir(parents=True)
(fe_comp / "App.vue").write_text("")
be_comp = tmp_path / "backend" / "src" / "components"
be_comp.mkdir(parents=True)
(be_comp / "Service.ts").write_text("export class Service {}")
modules = detect_modules(tmp_path)
names = [m["name"] for m in modules]
assert names.count("components") == 1
def test_detect_modules_backend_pg(tmp_path):
"""Test detection in backend-pg/src/ pattern (like vdolipoperek)."""
src = tmp_path / "backend-pg" / "src" / "services"
src.mkdir(parents=True)
(src / "tourMapper.js").write_text("const express = require('express');")
(src / "dbService.js").write_text("module.exports = { query };")
modules = detect_modules(tmp_path)
assert any(m["name"] == "services" for m in modules)
def test_detect_modules_monorepo(tmp_path):
"""Full monorepo: frontend/src/ + backend-pg/src/."""
# Frontend
fe_views = tmp_path / "frontend" / "src" / "views"
fe_views.mkdir(parents=True)
(fe_views / "Hotel.vue").write_text("")
fe_comp = tmp_path / "frontend" / "src" / "components"
fe_comp.mkdir(parents=True)
(fe_comp / "Search.vue").write_text("")
# Backend
be_svc = tmp_path / "backend-pg" / "src" / "services"
be_svc.mkdir(parents=True)
(be_svc / "db.js").write_text("const express = require('express');")
be_routes = tmp_path / "backend-pg" / "src" / "routes"
be_routes.mkdir(parents=True)
(be_routes / "api.js").write_text("const router = require('express').Router();")
modules = detect_modules(tmp_path)
names = {m["name"] for m in modules}
assert "views" in names
assert "components" in names
assert "services" in names
assert "routes" in names
# Check types
types = {m["name"]: m["type"] for m in modules}
assert types["views"] == "frontend"
assert types["components"] == "frontend"
# ---------------------------------------------------------------------------
# Decisions from CLAUDE.md
# ---------------------------------------------------------------------------
def test_extract_decisions(tmp_path):
(tmp_path / "CLAUDE.md").write_text("""# Project
## Rules
- Use WAL mode for SQLite
ВАЖНО: docker-compose v1 глючит → только raw docker commands
WORKAROUND: position:fixed breaks on iOS Safari, use transform instead
GOTCHA: Sletat API бан при параллельных запросах
FIXME: race condition in useSearch composable
## Known Issues
- Mobile bottom-sheet не работает в landscape mode
- CSS grid fallback для IE11 (но мы его не поддерживаем)
""")
decisions = extract_decisions_from_claude_md(tmp_path, "myproj", "My Project")
assert len(decisions) >= 4
types = {d["type"] for d in decisions}
assert "gotcha" in types
assert "workaround" in types
def test_extract_decisions_no_claude_md(tmp_path):
assert extract_decisions_from_claude_md(tmp_path) == []
def test_extract_decisions_filters_unrelated_sections(tmp_path):
"""Sections about Jitsi, Nextcloud, Prosody should be skipped."""
(tmp_path / "CLAUDE.md").write_text("""# vdolipoperek
## Known Issues
1. **Hotel ID mismatch** — Sletat GetTours vs GetHotels разные ID
2. **db.js export** — module.exports = pool (НЕ { pool })
## Jitsi + Nextcloud интеграция (2026-03-04)
ВАЖНО: JWT_APP_SECRET must be synced between Prosody and Nextcloud
GOTCHA: focus.meet.jitsi must be pinned in custom-config.js
## Prosody config
ВАЖНО: conf.d files принадлежат root → писать через docker exec
## Git Sync (2026-03-03)
ВАЖНО: Все среды синхронизированы на коммите 4ee5603
""")
decisions = extract_decisions_from_claude_md(tmp_path, "vdol", "vdolipoperek")
titles = [d["title"] for d in decisions]
# Should have the real known issues
assert any("Hotel ID mismatch" in t for t in titles)
assert any("db.js export" in t for t in titles)
# Should NOT have Jitsi/Prosody/Nextcloud noise
assert not any("JWT_APP_SECRET" in t for t in titles)
assert not any("focus.meet.jitsi" in t for t in titles)
assert not any("conf.d files" in t for t in titles)
def test_extract_decisions_filters_noise(tmp_path):
"""Commit hashes and shell commands should not be decisions."""
(tmp_path / "CLAUDE.md").write_text("""# Project
## Known Issues
1. **Real bug** — actual architectural issue that matters
- docker exec -it prosody bash
- ssh dev "cd /opt/project && git pull"
""")
decisions = extract_decisions_from_claude_md(tmp_path)
titles = [d["title"] for d in decisions]
assert any("Real bug" in t for t in titles)
# Shell commands should be filtered
assert not any("docker exec" in t for t in titles)
assert not any("ssh dev" in t for t in titles)
# ---------------------------------------------------------------------------
# Obsidian vault
# ---------------------------------------------------------------------------
def test_scan_obsidian(tmp_path):
# Create a mock vault
vault = tmp_path / "vault"
proj_dir = vault / "myproject"
proj_dir.mkdir(parents=True)
(proj_dir / "kanban.md").write_text("""---
kanban-plugin: board
---
## В работе
- [ ] Fix login page
- [ ] Add search filter
- [x] Setup CI/CD
## Done
- [x] Initial deploy
**ВАЖНО:** Не забыть обновить SSL сертификат
""")
(proj_dir / "notes.md").write_text("""# Notes
GOTCHA: API rate limit is 10 req/s
- [ ] Write tests for auth module
""")
result = scan_obsidian(vault, "myproject", "My Project", "myproject")
assert result["files_scanned"] == 2
assert len(result["tasks"]) >= 4 # 3 pending + at least 1 done
assert len(result["decisions"]) >= 1 # At least the ВАЖНО one
pending = [t for t in result["tasks"] if not t["done"]]
done = [t for t in result["tasks"] if t["done"]]
assert len(pending) >= 3
assert len(done) >= 1
def test_scan_obsidian_no_match(tmp_path):
vault = tmp_path / "vault"
vault.mkdir()
(vault / "other.md").write_text("# Unrelated note\nSomething else.")
result = scan_obsidian(vault, "myproject", "My Project")
assert result["files_scanned"] == 0
assert result["tasks"] == []
def test_find_vault_root_explicit(tmp_path):
vault = tmp_path / "vault"
vault.mkdir()
assert find_vault_root(vault) == vault
def test_find_vault_root_none():
assert find_vault_root(Path("/nonexistent/path")) is None
# ---------------------------------------------------------------------------
# Save to DB
# ---------------------------------------------------------------------------
def test_save_to_db(tmp_path):
conn = init_db(":memory:")
save_to_db(
conn,
project_id="test",
name="Test Project",
path=str(tmp_path),
tech_stack=["python", "fastapi"],
modules=[
{"name": "api", "type": "backend", "path": "src/api/", "file_count": 5},
{"name": "ui", "type": "frontend", "path": "src/ui/", "file_count": 8},
],
decisions=[
{"type": "gotcha", "title": "Bug X", "description": "desc",
"category": "ui"},
],
obsidian={
"tasks": [
{"title": "Fix login", "done": False, "source": "kanban"},
{"title": "Setup CI", "done": True, "source": "kanban"},
],
"decisions": [
{"type": "gotcha", "title": "API limit", "description": "10 req/s",
"category": "api", "source": "notes"},
],
"files_scanned": 2,
},
)
p = models.get_project(conn, "test")
assert p is not None
assert p["tech_stack"] == ["python", "fastapi"]
mods = models.get_modules(conn, "test")
assert len(mods) == 2
decs = models.get_decisions(conn, "test")
assert len(decs) == 2 # 1 from CLAUDE.md + 1 from Obsidian
tasks = models.list_tasks(conn, project_id="test")
assert len(tasks) == 2 # 2 from Obsidian
assert any(t["status"] == "done" for t in tasks)
assert any(t["status"] == "pending" for t in tasks)
conn.close()
# ---------------------------------------------------------------------------
# format_preview
# ---------------------------------------------------------------------------
def test_format_preview():
text = format_preview(
"vdol", "ВДОЛЬ", "~/projects/vdol",
["vue3", "typescript"],
[{"name": "search", "type": "frontend", "path": "src/search/", "file_count": 4}],
[{"type": "gotcha", "title": "Safari bug"}],
{"files_scanned": 3, "tasks": [
{"title": "Fix X", "done": False, "source": "kb"},
], "decisions": []},
)
assert "vue3" in text
assert "search" in text
assert "Safari bug" in text
assert "Fix X" in text
# ---------------------------------------------------------------------------
# CLI integration
# ---------------------------------------------------------------------------
def test_cli_bootstrap(tmp_path):
# Create a minimal project to bootstrap
proj = tmp_path / "myproj"
proj.mkdir()
(proj / "package.json").write_text(json.dumps({
"dependencies": {"vue": "^3.0"},
}))
src = proj / "src" / "components"
src.mkdir(parents=True)
(src / "App.vue").write_text("")
db_path = tmp_path / "test.db"
runner = CliRunner()
result = runner.invoke(cli, [
"--db", str(db_path),
"bootstrap", str(proj),
"--id", "myproj",
"--name", "My Project",
"--vault", str(tmp_path / "nonexistent_vault"),
"-y",
])
assert result.exit_code == 0
assert "vue3" in result.output
assert "Saved:" in result.output
# Verify in DB
conn = init_db(db_path)
p = models.get_project(conn, "myproj")
assert p is not None
assert "vue3" in p["tech_stack"]
conn.close()
def test_cli_bootstrap_already_exists(tmp_path):
proj = tmp_path / "myproj"
proj.mkdir()
db_path = tmp_path / "test.db"
runner = CliRunner()
# Create project first
runner.invoke(cli, ["--db", str(db_path), "project", "add", "myproj", "X", str(proj)])
# Try bootstrap — should fail
result = runner.invoke(cli, [
"--db", str(db_path),
"bootstrap", str(proj),
"--id", "myproj", "--name", "X", "-y",
])
assert result.exit_code == 1
assert "already exists" in result.output