diff --git a/agents/__init__.py b/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agents/bootstrap.py b/agents/bootstrap.py new file mode 100644 index 0000000..ca8d1d8 --- /dev/null +++ b/agents/bootstrap.py @@ -0,0 +1,564 @@ +""" +Kin bootstrap — auto-detect project tech stack, modules, and decisions. +Scans project directory, CLAUDE.md, and optionally Obsidian vault. +Writes results to kin.db via core.models. +""" + +import json +import re +from pathlib import Path +from typing import Any + +DEFAULT_VAULT = Path.home() / "Library" / "Mobile Documents" / "iCloud~md~obsidian" / "Documents" + +# --------------------------------------------------------------------------- +# Tech stack detection +# --------------------------------------------------------------------------- + +# package.json dependency → tech label +_NPM_MARKERS = { + "vue": "vue3", "nuxt": "nuxt3", "react": "react", "next": "nextjs", + "svelte": "svelte", "angular": "angular", + "typescript": "typescript", "vite": "vite", "webpack": "webpack", + "express": "express", "fastify": "fastify", "koa": "koa", + "pinia": "pinia", "vuex": "vuex", "redux": "redux", + "tailwindcss": "tailwind", "prisma": "prisma", "drizzle-orm": "drizzle", + "pg": "postgresql", "mysql2": "mysql", "better-sqlite3": "sqlite", + "axios": "axios", "puppeteer": "puppeteer", "playwright": "playwright", +} + +# Config files → tech label +_FILE_MARKERS = { + "nuxt.config.ts": "nuxt3", "nuxt.config.js": "nuxt3", + "vite.config.ts": "vite", "vite.config.js": "vite", + "tsconfig.json": "typescript", + "tailwind.config.js": "tailwind", "tailwind.config.ts": "tailwind", + "docker-compose.yml": "docker", "docker-compose.yaml": "docker", + "Dockerfile": "docker", + "go.mod": "go", "Cargo.toml": "rust", + "requirements.txt": "python", "pyproject.toml": "python", + "setup.py": "python", "Pipfile": "python", + ".eslintrc.js": "eslint", ".prettierrc": "prettier", +} + + +def detect_tech_stack(project_path: Path) -> list[str]: + """Detect tech stack from project files.""" + stack: set[str] = set() + + # Config file markers + for fname, tech in _FILE_MARKERS.items(): + # Check root and one level deep + if (project_path / fname).exists(): + stack.add(tech) + for sub in ("frontend", "backend", "server", "client", "app"): + if (project_path / sub / fname).exists(): + stack.add(tech) + + # package.json (root + subdirs) + for pj_path in _find_package_jsons(project_path): + stack.update(_parse_package_json(pj_path)) + + # requirements.txt + for req_path in project_path.glob("**/requirements.txt"): + if _is_inside_node_modules(req_path, project_path): + continue + stack.update(_parse_requirements_txt(req_path)) + + # go.mod + go_mod = project_path / "go.mod" + if go_mod.exists(): + stack.add("go") + text = go_mod.read_text(errors="replace") + if "gin-gonic" in text: + stack.add("gin") + if "fiber" in text: + stack.add("fiber") + + return sorted(stack) + + +def _find_package_jsons(root: Path) -> list[Path]: + """Find package.json files (root + immediate subdirs, skip node_modules).""" + results = [] + pj = root / "package.json" + if pj.exists(): + results.append(pj) + for sub in root.iterdir(): + if sub.is_dir() and sub.name != "node_modules" and not sub.name.startswith("."): + pj = sub / "package.json" + if pj.exists(): + results.append(pj) + return results + + +def _parse_package_json(path: Path) -> list[str]: + """Extract tech labels from package.json.""" + try: + data = json.loads(path.read_text(errors="replace")) + except (json.JSONDecodeError, OSError): + return [] + stack = [] + all_deps = {} + for key in ("dependencies", "devDependencies"): + all_deps.update(data.get(key, {})) + for dep_name, tech in _NPM_MARKERS.items(): + if dep_name in all_deps: + stack.append(tech) + return stack + + +def _parse_requirements_txt(path: Path) -> list[str]: + """Extract tech labels from requirements.txt.""" + markers = { + "fastapi": "fastapi", "flask": "flask", "django": "django", + "sqlalchemy": "sqlalchemy", "celery": "celery", "redis": "redis", + "pydantic": "pydantic", "click": "click", "pytest": "pytest", + } + stack = [] + try: + text = path.read_text(errors="replace").lower() + except OSError: + return stack + for pkg, tech in markers.items(): + if pkg in text: + stack.append(tech) + return stack + + +def _is_inside_node_modules(path: Path, root: Path) -> bool: + rel = path.relative_to(root) + return "node_modules" in rel.parts + + +# --------------------------------------------------------------------------- +# Module detection +# --------------------------------------------------------------------------- + +_FRONTEND_EXTS = {".vue", ".jsx", ".tsx", ".svelte"} +_BACKEND_MARKERS = {"express", "fastify", "koa", "router", "controller", "middleware"} + + +def detect_modules(project_path: Path) -> list[dict]: + """Scan src/ (or app/, lib/, frontend/, backend/) for modules.""" + modules = [] + scan_dirs = [] + + # Prioritized source dirs + for name in ("src", "app", "lib", "frontend", "backend", "server", "client"): + d = project_path / name + if d.is_dir(): + scan_dirs.append(d) + + # Also check frontend/src, backend/src patterns + for name in ("frontend/src", "backend/src", "backend-pg/src"): + d = project_path / name + if d.is_dir(): + scan_dirs.append(d) + + seen = set() + for scan_dir in scan_dirs: + for child in sorted(scan_dir.iterdir()): + if not child.is_dir() or child.name.startswith(".") or child.name == "node_modules": + continue + mod = _analyze_module(child, project_path) + key = (mod["name"], mod["path"]) + if key not in seen: + seen.add(key) + modules.append(mod) + + return modules + + +def _analyze_module(dir_path: Path, project_root: Path) -> dict: + """Analyze a directory to determine module type and file count.""" + rel_path = str(dir_path.relative_to(project_root)) + "/" + files = list(dir_path.rglob("*")) + source_files = [f for f in files if f.is_file() and not f.name.startswith(".")] + file_count = len(source_files) + + # Determine type + exts = {f.suffix for f in source_files} + mod_type = _guess_module_type(dir_path, exts, source_files) + + return { + "name": dir_path.name, + "type": mod_type, + "path": rel_path, + "file_count": file_count, + } + + +def _guess_module_type(dir_path: Path, exts: set[str], files: list[Path]) -> str: + """Guess if module is frontend, backend, shared, or infra.""" + # Obvious frontend + if exts & _FRONTEND_EXTS: + return "frontend" + + # Check file contents for backend markers + has_backend_marker = False + for f in files[:20]: # Sample first 20 files + if f.suffix in (".ts", ".js", ".mjs"): + try: + text = f.read_text(errors="replace")[:2000] + text_lower = text.lower() + if any(m in text_lower for m in _BACKEND_MARKERS): + has_backend_marker = True + break + except OSError: + continue + + if has_backend_marker: + return "backend" + + # Infra patterns + name = dir_path.name.lower() + if name in ("infra", "deploy", "scripts", "ci", "docker", "nginx", "config"): + return "infra" + + # Shared by default if ambiguous + if exts & {".ts", ".js", ".py"}: + return "shared" + + return "shared" + + +# --------------------------------------------------------------------------- +# Decisions from CLAUDE.md +# --------------------------------------------------------------------------- + +_DECISION_PATTERNS = [ + (r"(?i)\b(GOTCHA|ВАЖНО|WARNING|ВНИМАНИЕ)[:\s]+(.*?)(?=\n[#\-]|\n\n|\Z)", "gotcha"), + (r"(?i)\b(WORKAROUND|ОБХОДНОЙ|ХАК)[:\s]+(.*?)(?=\n[#\-]|\n\n|\Z)", "workaround"), + (r"(?i)\b(FIXME|TODO|БАГИ?)[:\s]+(.*?)(?=\n[#\-]|\n\n|\Z)", "gotcha"), + (r"(?i)\b(РЕШЕНИЕ|DECISION)[:\s]+(.*?)(?=\n[#\-]|\n\n|\Z)", "decision"), + (r"(?i)\b(CONVENTION|СОГЛАШЕНИЕ|ПРАВИЛО)[:\s]+(.*?)(?=\n[#\-]|\n\n|\Z)", "convention"), +] + +# Section headers that likely contain decisions +_DECISION_SECTIONS = [ + r"(?i)known\s+issues?", r"(?i)workaround", r"(?i)gotcha", + r"(?i)решени[яе]", r"(?i)грабл[ия]", r"(?i)важно", + r"(?i)conventions?", r"(?i)правила", r"(?i)нюансы", +] + + +def extract_decisions_from_claude_md(project_path: Path) -> list[dict]: + """Parse CLAUDE.md for decisions, gotchas, workarounds.""" + claude_md = project_path / "CLAUDE.md" + if not claude_md.exists(): + return [] + + try: + text = claude_md.read_text(errors="replace") + except OSError: + return [] + + decisions = [] + seen_titles = set() + + # Pattern-based extraction + for pattern, dec_type in _DECISION_PATTERNS: + for m in re.finditer(pattern, text, re.DOTALL): + label = m.group(1).strip() + body = m.group(2).strip() + if not body or len(body) < 10: + continue + # First line as title, rest as description + lines = body.split("\n") + title = lines[0].strip().rstrip(".")[:100] + desc = body + if title not in seen_titles: + seen_titles.add(title) + decisions.append({ + "type": dec_type, + "title": title, + "description": desc, + "category": _guess_category(title + " " + desc), + }) + + # Section-based extraction: find headers matching decision sections + sections = re.split(r"(?m)^(#{1,4}\s+.*?)$", text) + for i, section in enumerate(sections): + if any(re.search(pat, section) for pat in _DECISION_SECTIONS): + # The content is in the next section + if i + 1 < len(sections): + content = sections[i + 1].strip() + # Extract bullet points + for line in content.split("\n"): + line = line.strip() + if line.startswith(("- ", "* ", "• ")): + item = line.lstrip("-*• ").strip() + if item and len(item) > 10 and item[:80] not in seen_titles: + seen_titles.add(item[:80]) + decisions.append({ + "type": "decision", + "title": item[:100], + "description": item, + "category": _guess_category(item), + }) + + return decisions + + +def _guess_category(text: str) -> str: + """Best-effort category guess from text content.""" + t = text.lower() + if any(w in t for w in ("css", "ui", "vue", "компонент", "стил", "layout", "mobile", "safari", "bottom-sheet")): + return "ui" + if any(w in t for w in ("api", "endpoint", "rest", "route", "запрос", "fetch")): + return "api" + if any(w in t for w in ("sql", "база", "миграц", "postgres", "sqlite", "бд", "schema")): + return "architecture" + if any(w in t for w in ("безопас", "security", "xss", "auth", "token", "csrf", "injection")): + return "security" + if any(w in t for w in ("docker", "deploy", "nginx", "ci", "cd", "infra", "сервер")): + return "devops" + if any(w in t for w in ("performance", "cache", "оптимиз", "lazy", "скорость")): + return "performance" + return "architecture" + + +# --------------------------------------------------------------------------- +# Obsidian vault scanning +# --------------------------------------------------------------------------- + +def find_vault_root(vault_path: Path | None = None) -> Path | None: + """Find the Obsidian vault root directory. + + If vault_path is given but doesn't exist, returns None (don't fallback). + If vault_path is None, tries the default iCloud Obsidian location. + """ + if vault_path is not None: + return vault_path if vault_path.is_dir() else None + + # Default: iCloud Obsidian path + default = DEFAULT_VAULT + if default.is_dir(): + # Look for a vault inside (usually one level deep) + for child in default.iterdir(): + if child.is_dir() and not child.name.startswith("."): + return child + return None + + +def scan_obsidian( + vault_root: Path, + project_id: str, + project_name: str, + project_dir_name: str | None = None, +) -> dict: + """Scan Obsidian vault for project-related notes. + + Returns {"tasks": [...], "decisions": [...], "files_scanned": int} + """ + result = {"tasks": [], "decisions": [], "files_scanned": 0} + + # Build search terms + search_terms = {project_id.lower()} + if project_name: + search_terms.add(project_name.lower()) + if project_dir_name: + search_terms.add(project_dir_name.lower()) + + # Find project folder in vault + project_files: list[Path] = [] + for term in list(search_terms): + for child in vault_root.iterdir(): + if child.is_dir() and term in child.name.lower(): + for f in child.rglob("*.md"): + if f not in project_files: + project_files.append(f) + + # Also search for files mentioning the project by name + for md_file in vault_root.glob("*.md"): + try: + text = md_file.read_text(errors="replace")[:5000].lower() + except OSError: + continue + if any(term in text for term in search_terms): + if md_file not in project_files: + project_files.append(md_file) + + result["files_scanned"] = len(project_files) + + for f in project_files: + try: + text = f.read_text(errors="replace") + except OSError: + continue + + _extract_obsidian_tasks(text, f.stem, result["tasks"]) + _extract_obsidian_decisions(text, f.stem, result["decisions"]) + + return result + + +def _extract_obsidian_tasks(text: str, source: str, tasks: list[dict]): + """Extract checkbox items from Obsidian markdown.""" + for m in re.finditer(r"^[-*]\s+\[([ xX])\]\s+(.+)$", text, re.MULTILINE): + done = m.group(1).lower() == "x" + title = m.group(2).strip() + # Remove Obsidian wiki-links + title = re.sub(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]", r"\1", title) + if len(title) > 5: + tasks.append({ + "title": title[:200], + "done": done, + "source": source, + }) + + +def _extract_obsidian_decisions(text: str, source: str, decisions: list[dict]): + """Extract decisions/gotchas from Obsidian notes.""" + for pattern, dec_type in _DECISION_PATTERNS: + for m in re.finditer(pattern, text, re.DOTALL): + body = m.group(2).strip() + if body and len(body) > 10: + title = body.split("\n")[0].strip()[:100] + decisions.append({ + "type": dec_type, + "title": title, + "description": body, + "category": _guess_category(body), + "source": source, + }) + + # Also look for ВАЖНО/GOTCHA/FIXME inline markers not caught above + for m in re.finditer(r"(?i)\*\*(ВАЖНО|GOTCHA|FIXME)\*\*[:\s]*(.*?)(?=\n|$)", text): + body = m.group(2).strip() + if body and len(body) > 10: + decisions.append({ + "type": "gotcha", + "title": body[:100], + "description": body, + "category": _guess_category(body), + "source": source, + }) + + +# --------------------------------------------------------------------------- +# Formatting for CLI preview +# --------------------------------------------------------------------------- + +def format_preview( + project_id: str, + name: str, + path: str, + tech_stack: list[str], + modules: list[dict], + decisions: list[dict], + obsidian: dict | None = None, +) -> str: + """Format bootstrap results for user review.""" + lines = [ + f"Project: {project_id} — {name}", + f"Path: {path}", + "", + f"Tech stack: {', '.join(tech_stack) if tech_stack else '(not detected)'}", + "", + ] + + if modules: + lines.append(f"Modules ({len(modules)}):") + for m in modules: + lines.append(f" {m['name']} ({m['type']}) — {m['path']} ({m['file_count']} files)") + else: + lines.append("Modules: (none detected)") + lines.append("") + + if decisions: + lines.append(f"Decisions from CLAUDE.md ({len(decisions)}):") + for i, d in enumerate(decisions, 1): + lines.append(f" #{i} {d['type']}: {d['title']}") + else: + lines.append("Decisions from CLAUDE.md: (none found)") + + if obsidian: + lines.append("") + lines.append(f"Obsidian vault ({obsidian['files_scanned']} files scanned):") + if obsidian["tasks"]: + pending = [t for t in obsidian["tasks"] if not t["done"]] + done = [t for t in obsidian["tasks"] if t["done"]] + lines.append(f" Tasks: {len(pending)} pending, {len(done)} done") + for t in pending[:10]: + lines.append(f" [ ] {t['title']}") + if len(pending) > 10: + lines.append(f" ... and {len(pending) - 10} more") + for t in done[:5]: + lines.append(f" [x] {t['title']}") + if len(done) > 5: + lines.append(f" ... and {len(done) - 5} more done") + else: + lines.append(" Tasks: (none found)") + if obsidian["decisions"]: + lines.append(f" Decisions: {len(obsidian['decisions'])}") + for d in obsidian["decisions"][:5]: + lines.append(f" {d['type']}: {d['title']} (from {d['source']})") + if len(obsidian["decisions"]) > 5: + lines.append(f" ... and {len(obsidian['decisions']) - 5} more") + else: + lines.append(" Decisions: (none found)") + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Write to DB +# --------------------------------------------------------------------------- + +def save_to_db( + conn, + project_id: str, + name: str, + path: str, + tech_stack: list[str], + modules: list[dict], + decisions: list[dict], + obsidian: dict | None = None, +): + """Save all bootstrap data to kin.db via models.""" + from core import models + + # Create project + claude_md = Path(path).expanduser() / "CLAUDE.md" + models.create_project( + conn, project_id, name, path, + tech_stack=tech_stack, + claude_md_path=str(claude_md) if claude_md.exists() else None, + ) + + # Add modules + for m in modules: + models.add_module( + conn, project_id, m["name"], m["type"], m["path"], + description=f"{m['file_count']} files", + ) + + # Add decisions from CLAUDE.md + for d in decisions: + models.add_decision( + conn, project_id, d["type"], d["title"], d["description"], + category=d.get("category"), + ) + + # Add Obsidian decisions + if obsidian: + for d in obsidian.get("decisions", []): + models.add_decision( + conn, project_id, d["type"], d["title"], d["description"], + category=d.get("category"), + tags=[f"obsidian:{d['source']}"], + ) + + # Import Obsidian tasks + task_num = 1 + for t in obsidian.get("tasks", []): + task_id = f"{project_id.upper()}-OBS-{task_num:03d}" + status = "done" if t["done"] else "pending" + models.create_task( + conn, task_id, project_id, t["title"], + status=status, + brief={"source": f"obsidian:{t['source']}"}, + ) + task_num += 1 diff --git a/cli/main.py b/cli/main.py index e018127..9fde1d3 100644 --- a/cli/main.py +++ b/cli/main.py @@ -14,6 +14,10 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from core.db import init_db from core import models +from agents.bootstrap import ( + detect_tech_stack, detect_modules, extract_decisions_from_claude_md, + find_vault_root, scan_obsidian, format_preview, save_to_db, +) DEFAULT_DB = Path.home() / ".kin" / "kin.db" @@ -404,6 +408,71 @@ def cost(ctx, period): click.echo(f"\nTotal: ${total:.4f}") +# =========================================================================== +# bootstrap +# =========================================================================== + +@cli.command("bootstrap") +@click.argument("path", type=click.Path(exists=True)) +@click.option("--id", "project_id", required=True, help="Short project ID (e.g. vdol)") +@click.option("--name", required=True, help="Project display name") +@click.option("--vault", "vault_path", type=click.Path(), default=None, + help="Obsidian vault path (auto-detected if omitted)") +@click.option("-y", "--yes", is_flag=True, help="Skip confirmation") +@click.pass_context +def bootstrap(ctx, path, project_id, name, vault_path, yes): + """Auto-detect project stack, modules, decisions and import into Kin.""" + conn = ctx.obj["conn"] + project_path = Path(path).expanduser().resolve() + + # Check if project already exists + existing = models.get_project(conn, project_id) + if existing: + click.echo(f"Project '{project_id}' already exists. Use 'kin project show {project_id}'.", err=True) + raise SystemExit(1) + + # Detect everything + click.echo(f"Scanning {project_path} ...") + tech_stack = detect_tech_stack(project_path) + modules = detect_modules(project_path) + decisions = extract_decisions_from_claude_md(project_path) + + # Obsidian + obsidian = None + vault_root = find_vault_root(Path(vault_path) if vault_path else None) + if vault_root: + dir_name = project_path.name + obsidian = scan_obsidian(vault_root, project_id, name, dir_name) + if not obsidian["tasks"] and not obsidian["decisions"]: + obsidian = None # Nothing found, don't clutter output + + # Preview + click.echo("") + click.echo(format_preview( + project_id, name, str(project_path), tech_stack, + modules, decisions, obsidian, + )) + click.echo("") + + if not yes: + if not click.confirm("Save to kin.db?"): + click.echo("Aborted.") + return + + save_to_db(conn, project_id, name, str(project_path), + tech_stack, modules, decisions, obsidian) + + # Summary + task_count = 0 + dec_count = len(decisions) + if obsidian: + task_count += len(obsidian.get("tasks", [])) + dec_count += len(obsidian.get("decisions", [])) + + click.echo(f"Saved: 1 project, {len(modules)} modules, " + f"{dec_count} decisions, {task_count} tasks.") + + # =========================================================================== # Entry point # =========================================================================== diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py new file mode 100644 index 0000000..a11c85d --- /dev/null +++ b/tests/test_bootstrap.py @@ -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("") + (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_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("") + + 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