"""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