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>
427 lines
14 KiB
Python
427 lines
14 KiB
Python
"""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
|