kin/tests/test_bootstrap.py
johnfrum1234 e5444114bd Fix bootstrap: deep scan, CLAUDE.md fallback, noise filtering
1. Tech stack: recursive file search (depth 3) + CLAUDE.md text fallback
   when config files are on remote server (detects nodejs, postgresql, etc.)
2. Modules: scan */src/ patterns in top-level dirs (frontend/src/, backend-pg/src/)
3. Decisions: filter out unrelated sections (Jitsi, Nextcloud, Prosody, GOIP),
   filter noise (commit hashes, shell commands, external service paths).
   Noise filtering also applied to Obsidian decisions.

Tested on vdolipoperek: 4 tech, 5 modules, 9 clean decisions, 24 Obsidian tasks.
61 tests, all passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 13:37:42 +02:00

427 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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("<template></template>")
(src / "components" / "search" / "SearchFilter.vue").write_text("<template></template>")
(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_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("<template></template>")
fe_comp = tmp_path / "frontend" / "src" / "components"
fe_comp.mkdir(parents=True)
(fe_comp / "Search.vue").write_text("<template></template>")
# 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("<template></template>")
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