Add bootstrap command — auto-detect project stack, modules, decisions
kin bootstrap <path> --id <id> --name <name> [--vault <path>] Detects: package.json, requirements.txt, go.mod, config files → tech_stack. Scans src/app/lib/frontend/backend dirs → modules with type detection. Parses CLAUDE.md for GOTCHA/WORKAROUND/FIXME/ВАЖНО → decisions. Scans Obsidian vault for kanban tasks, checkboxes, and decisions. Preview before save, -y to skip confirmation. 18 bootstrap tests, 57 total passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
432cfd55d4
commit
da4a8aae72
4 changed files with 957 additions and 0 deletions
324
tests/test_bootstrap.py
Normal file
324
tests/test_bootstrap.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
"""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_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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
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) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue