diff --git a/CHANGELOG.md b/CHANGELOG.md index a8e48a6..adc39b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog / 更新日志 +## [0.3.0] - 2026-05-15 + +- Phase 2: Deep AI context generation with 15+ analysis dimensions (import style, module system, naming convention, state management, styling approach, API style, architecture pattern, directory purposes). / 深度 AI 上下文生成,15+ 分析维度。 +- Phase 2: Rich `.cursorrules`, `CLAUDE.md`, `copilot-instructions.md` with project structure, API routes, and tech stack details. / 富内容 AI 上下文文件,包含项目结构、API 路由和技术栈详情。 +- Phase 2: `analysis/ast_lite.py` — regex-based code analysis engine. / 基于正则的代码分析引擎。 +- Phase 3: `acorn sync` — detect and fix stale AI context files. / 检测并修复过时的 AI 上下文文件。 +- Phase 3: `acorn sync --sync-hook` — install pre-commit git hook. / 安装 pre-commit git 钩子。 + +## [0.2.0] - 2026-05-15 + +- Phase 1: Pivot to AI coding environment optimizer. / 转型为 AI 编码环境优化器。 +- `acorn doctor` — 7-check health report with auto-fix prompt. / 7 项健康检查 + 自动修复。 +- `acorn fix` — targeted file generation (Dockerfile, AI files, gitignore, etc.). / 定向文件生成。 +- Unified `generators/builtin.py` with 9-language conventions. / 统一内置生成器,9 种语言约定。 +- `acorn` no-args → doctor (if source exists) or wizard (if empty). / 无参数行为变更。 + ## [0.1.0] - 2024-01-01 - Initial alpha release. / 初始 Alpha 版本。 diff --git a/pyproject.toml b/pyproject.toml index 4f7fa91..7654f08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "acorn" -version = "0.2.0" +version = "0.3.0" description = "智能项目初始化工具 - 自动检测项目类型、匹配模板、生成配置" readme = "README.md" license = "MIT" diff --git a/src/acorn/__init__.py b/src/acorn/__init__.py index d3ec452..493f741 100644 --- a/src/acorn/__init__.py +++ b/src/acorn/__init__.py @@ -1 +1 @@ -__version__ = "0.2.0" +__version__ = "0.3.0" diff --git a/src/acorn/analysis/ast_lite.py b/src/acorn/analysis/ast_lite.py new file mode 100644 index 0000000..c071094 --- /dev/null +++ b/src/acorn/analysis/ast_lite.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +import re +from pathlib import Path + +SOURCE_EXTENSIONS_TS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"} +MAX_SCAN_FILES = 50 + + +def _iter_source(dir_path: Path, max_files: int = MAX_SCAN_FILES) -> list[Path]: + count = 0 + results = [] + for f in dir_path.rglob("*"): + if f.suffix in SOURCE_EXTENSIONS_TS and f.is_file(): + results.append(f) + count += 1 + if count >= max_files: + break + return results + + +def _read_safe(path: Path) -> str | None: + try: + return path.read_text("utf-8", errors="ignore") + except OSError: + return None + + +def detect_import_style(dir_path: Path) -> str: + path_alias_patterns = [ + re.compile(r'from\s+["\']@/'), + re.compile(r'from\s+["\']~/'), + re.compile(r'from\s+["\']\$/'), + re.compile(r'from\s+["\']@\w+/'), + ] + for f in _iter_source(dir_path): + content = _read_safe(f) + if not content: + continue + if any(p.search(content) for p in path_alias_patterns): + return "path-alias" + if re.search(r'from\s+["\']\.\.?/', content): + return "relative" + return "unknown" + + +def detect_module_system(dir_path: Path) -> str: + has_esm_import = False + has_cjs_require = False + for f in _iter_source(dir_path): + content = _read_safe(f) + if not content: + continue + if re.search(r'^\s*import\s+', content, re.MULTILINE): + has_esm_import = True + if re.search(r'^\s*(?:const|let|var)\s+\w+\s*=\s*require\(', content, re.MULTILINE): + has_cjs_require = True + pkg = _find_nearest_package_json(f) + if pkg and _read_safe(pkg): + import json + try: + data = json.loads(_read_safe(pkg) or "{}") + if data.get("type") == "module": + return "esm" + if data.get("type") == "commonjs": + return "commonjs" + except (json.JSONDecodeError, OSError): + pass + if has_esm_import and not has_cjs_require: + return "esm" + if has_cjs_require and not has_esm_import: + return "commonjs" + return "mixed" if has_esm_import and has_cjs_require else "unknown" + + +def _find_nearest_package_json(path: Path) -> Path | None: + for parent in [path] + list(path.parents): + candidate = parent / "package.json" + if candidate.is_file(): + return candidate + return None + + +def detect_naming_convention(dir_path: Path) -> str: + scores = {"camelCase": 0, "snake_case": 0, "kebab-case": 0, "PascalCase": 0} + for f in _iter_source(dir_path): + name = f.stem + if re.match(r'^[a-z]+[a-zA-Z0-9]*$', name): + scores["camelCase"] += 1 + elif re.match(r'^[a-z]+(_[a-z0-9]+)*$', name): + scores["snake_case"] += 1 + elif re.match(r'^[a-z]+(-[a-z0-9]+)*$', name): + scores["kebab-case"] += 1 + elif re.match(r'^[A-Z][a-zA-Z0-9]*$', name): + scores["PascalCase"] += 1 + best = max(scores, key=scores.get) + return best if scores[best] > 0 else "unknown" + + +def detect_state_management(dir_path: Path) -> str | None: + pkg_json = dir_path / "package.json" + if not pkg_json.is_file(): + return None + content = _read_safe(pkg_json) + if not content: + return None + import json + try: + data = json.loads(content) + deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})} + except json.JSONDecodeError: + return None + sm_map = { + "zustand": "Zustand", "redux": "Redux", "@reduxjs/toolkit": "Redux Toolkit", + "pinia": "Pinia", "valtio": "Valtio", "jotai": "Jotai", + "recoil": "Recoil", "mobx": "MobX", "mobx-react": "MobX", + "vuex": "Vuex", "ngrx": "NgRx", + } + for dep, label in sm_map.items(): + if dep in deps: + return label + return None + + +def detect_styling_approach(dir_path: Path) -> str | None: + config_files = [ + "tailwind.config.js", "tailwind.config.ts", "tailwind.config.cjs", + "unocss.config.ts", "unocss.config.js", + ] + for cf in config_files: + if (dir_path / cf).is_file(): + return "tailwindcss" if cf.startswith("tailwind") else "unocss" + pkg_json = dir_path / "package.json" + if pkg_json.is_file(): + content = _read_safe(pkg_json) + if content: + import json + try: + data = json.loads(content) + deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})} + except json.JSONDecodeError: + deps = {} + style_map = { + "tailwindcss": "tailwindcss", "unocss": "unocss", + "styled-components": "styled-components", + "@emotion/react": "emotion", "@emotion/styled": "emotion", + "sass": "sass", "less": "less", "stylus": "stylus", + "@stitches/react": "Stitches", + } + for dep, label in style_map.items(): + if dep in deps: + return label + if list(dir_path.rglob("*.module.css")): + return "css-modules" + return None + + +def detect_api_style(dir_path: Path) -> str | None: + pkg_json = dir_path / "package.json" + if pkg_json.is_file(): + content = _read_safe(pkg_json) + if content: + import json + try: + data = json.loads(content) + deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})} + except json.JSONDecodeError: + deps = {} + if "@trpc/client" in deps or "@trpc/server" in deps or "trpc" in deps: + return "tRPC" + if "graphql" in deps or "@graphql-codegen" in deps: + return "GraphQL" + if "grpc" in deps or "@grpc/grpc-js" in deps: + return "gRPC" + for f in _iter_source(dir_path): + content = _read_safe(f) + if not content: + continue + if "tRPC" in content or "trpc" in content: + return "tRPC" + if list(dir_path.rglob("*.graphql")) or list(dir_path.rglob("*.gql")): + return "GraphQL" + if pkg_json.is_file(): + if list(dir_path.rglob("route.ts")) or list(dir_path.rglob("route.js")): + return "REST (file-based)" + return None + + +def detect_architecture_pattern(dir_path: str | Path, language: str) -> str | None: + dir_path = Path(dir_path) if isinstance(dir_path, str) else dir_path + if language == "node": + if (dir_path / "app").is_dir() or (dir_path / "src/app").is_dir(): + return "app-router" + if (dir_path / "pages").is_dir() or (dir_path / "src/pages").is_dir(): + return "pages-router" + if (dir_path / "lerna.json").is_file() or (dir_path / "pnpm-workspace.yaml").is_file(): + return "monorepo" + if (dir_path / "turbo.json").is_file(): + return "monorepo (turborepo)" + if (dir_path / "nx.json").is_file(): + return "monorepo (nx)" + if language == "python": + if (dir_path / "apps").is_dir() and (dir_path / "pyproject.toml").is_file(): + return "monorepo" + return None + + +def detect_entry_points(dir_path: Path, language: str) -> list[str]: + patterns = { + "node": ["index.js", "index.ts", "app.js", "app.ts", "server.js", "server.ts", "main.js", "main.ts"], + "python": ["main.py", "app.py", "wsgi.py", "manage.py", "run.py"], + "go": ["main.go", "cmd/server/main.go", "cmd/api/main.go"], + "rust": ["src/main.rs"], + "java": ["src/main/java/**/Application.java"], + "ruby": ["app.rb", "config.ru", "bin/rails"], + "php": ["public/index.php", "index.php", "artisan"], + } + found = [] + for pattern in patterns.get(language, []): + if "*" in pattern: + for f in dir_path.glob(pattern): + found.append(str(f.relative_to(dir_path))) + else: + f = dir_path / pattern + if f.is_file(): + found.append(pattern) + return found + + +def detect_directory_purpose(structure: dict[str, list[str]], language: str) -> dict[str, str]: + purposes: dict[str, str] = {} + known: dict[str, dict[str, str]] = { + "node": { + "app": "Route handlers and pages", + "pages": "Page components", + "components": "Reusable React components", + "lib": "Utility functions and shared logic", + "utils": "Utility functions", + "server": "Server-side code", + "api": "API route handlers", + "hooks": "Custom React hooks", + "styles": "CSS/style files", + "public": "Static assets", + "types": "TypeScript type definitions", + "db": "Database schema and queries", + "config": "Configuration files", + "middleware": "Express/Koa middleware", + "routes": "Route definitions", + "controllers": "Controller logic", + "models": "Data models", + "services": "Business logic layer", + "validators": "Input validation", + "tests": "Test files", + "migrations": "Database migrations", + "seeds": "Database seed data", + }, + "python": { + "app": "Application entry point", + "api": "API route handlers", + "routes": "Route definitions", + "models": "SQLAlchemy/Django models", + "schemas": "Pydantic/marshmallow schemas", + "services": "Business logic layer", + "utils": "Utility functions", + "tests": "Test files", + "migrations": "Database migrations", + "config": "Configuration", + "middleware": "Middleware", + "templates": "Jinja2 templates", + }, + } + for top_dir, files in structure.items(): + for lang_key, mapping in known.items(): + if language == lang_key and top_dir in mapping: + purposes[top_dir] = mapping[top_dir] + break + else: + purposes[top_dir] = "Unknown" + return purposes diff --git a/src/acorn/analysis/insights.py b/src/acorn/analysis/insights.py index dc0d476..70fdf64 100644 --- a/src/acorn/analysis/insights.py +++ b/src/acorn/analysis/insights.py @@ -6,6 +6,7 @@ from pathlib import Path from acorn._compat import tomllib +from acorn.analysis import ast_lite SOURCE_EXTENSIONS = {".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".java", ".rb", ".php", ".c", ".cpp", ".h", ".hpp"} IGNORE_DIRS = { @@ -37,6 +38,15 @@ class ProjectInsights: entry_points: list[str] = field(default_factory=list) + import_style: str = "unknown" + module_system: str = "unknown" + naming_convention: str = "unknown" + state_management: str | None = None + styling_approach: str | None = None + api_style: str | None = None + architecture_pattern: str | None = None + directory_purposes: dict[str, str] = field(default_factory=dict) + _JS_FRAMEWORKS: dict[str, tuple[str | None, str | None, str | None]] = { "next": ("Next.js", None, None), @@ -333,6 +343,16 @@ def analyze(dir_path: Path | str) -> ProjectInsights: _analyze_src_structure(ins, dir_path) _find_entry_points(ins, dir_path) + if ins.language == "node": + ins.import_style = ast_lite.detect_import_style(dir_path) + ins.module_system = ast_lite.detect_module_system(dir_path) + ins.naming_convention = ast_lite.detect_naming_convention(dir_path) + ins.state_management = ast_lite.detect_state_management(dir_path) + ins.styling_approach = ast_lite.detect_styling_approach(dir_path) + ins.api_style = ast_lite.detect_api_style(dir_path) + ins.architecture_pattern = ast_lite.detect_architecture_pattern(dir_path, "node") + ins.directory_purposes = ast_lite.detect_directory_purpose(ins.src_structure, "node") + return ins diff --git a/src/acorn/cli.py b/src/acorn/cli.py index a3604e0..d5d3f3c 100644 --- a/src/acorn/cli.py +++ b/src/acorn/cli.py @@ -12,6 +12,7 @@ from acorn.commands.doctor import cmd_doctor from acorn.commands.docker import cmd_add_ci, cmd_dockerize from acorn.commands.fix import cmd_fix +from acorn.commands.sync import cmd_sync from acorn.commands.generate import cmd_generate from acorn.commands.marketplace import cmd_install, cmd_search from acorn.commands.template_cmd import cmd_add, cmd_init, cmd_list, cmd_remove, cmd_validate, cmd_validate_ai_context @@ -93,6 +94,10 @@ def build_parser() -> argparse.ArgumentParser: g_admin.add_argument("--scan", metavar="PATH", help="扫描模板或项目的安全问题") g_admin.add_argument("--config", metavar="FILE", help="指定全局配置文件路径") + g_sync = parser.add_argument_group("sync options") + g_sync.add_argument("--sync", action="store_true", help="同步 AI 上下文文件 / Sync AI context files") + g_sync.add_argument("--sync-hook", action="store_true", dest="sync_hook", help="安装 pre-commit hook / Install pre-commit hook") + g_fix = parser.add_argument_group("fix options") g_fix.add_argument("--fix", action="store_true", help="修复项目配置(子命令模式)") g_fix.add_argument("--fix-dockerfile", action="store_true", dest="fix_dockerfile", help="生成 Dockerfile") @@ -143,6 +148,8 @@ def main() -> int: sys.argv = [sys.argv[0], "--wizard"] + sys.argv[2:] if len(sys.argv) >= 2 and sys.argv[1] == "fix": sys.argv = [sys.argv[0], "--fix"] + sys.argv[2:] + if len(sys.argv) >= 2 and sys.argv[1] == "sync": + sys.argv = [sys.argv[0], "--sync"] + sys.argv[2:] parser = build_parser() args = parser.parse_args() @@ -202,6 +209,8 @@ def main() -> int: if args.validate: return cmd_validate(args.validate) + if args.sync: + return cmd_sync(cwd=Path(args.dir).resolve(), hook_install=args.sync_hook, force=args.force, dry_run=args.dry_run) if args.fix: return cmd_fix(args) diff --git a/src/acorn/commands/sync.py b/src/acorn/commands/sync.py new file mode 100644 index 0000000..74fe061 --- /dev/null +++ b/src/acorn/commands/sync.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from acorn.analysis.detector import detect_project_type +from acorn.analysis.insights import analyze, has_source_code +from acorn.format import EXIT_ERROR, EXIT_SUCCESS, color, confirm_or_exit +from acorn.generators.builtin import AI_FILES, GENERATORS, generate_file_content +from acorn.log import debug as log_debug, error as log_error, info as log_info + +SYNC_TARGETS = { + "cursorrules": ".cursorrules", + "claude-md": "CLAUDE.md", + "copilot": ".github/copilot-instructions.md", +} + +HOOK_TEMPLATE = """#!/bin/sh +# Acorn sync hook — keep AI context files up to date +exec acorn sync +""" + + +def _detect_drift(cwd: Path, detection, insights) -> list[dict]: + drifted: list[dict] = [] + project_type = detection.project_type.value if detection else "unknown" + + for target_name, dest_name in SYNC_TARGETS.items(): + dest = cwd / dest_name + if not dest.exists(): + continue + + current = dest.read_text(encoding="utf-8", errors="ignore") + expected = generate_file_content(dest_name, project_type, detection=detection, insights=insights) + + if current.strip() != expected.strip(): + drifted.append({ + "target": target_name, + "dest": dest_name, + "dest_path": dest, + "expected": expected, + }) + + return drifted + + +def _print_drift(drifted: list[dict]) -> None: + for item in drifted: + dest = item["dest"] + print(f" {color('⚠', 'yellow')} {dest} — stale, needs update") + + +def _regenerate(drifted: list[dict], force: bool = False, dry_run: bool = False) -> int: + updated = 0 + for item in drifted: + dest = item["dest_path"] + expected = item["expected"] + dest.parent.mkdir(parents=True, exist_ok=True) + + if dry_run: + print(f" {color('Would update:', 'dim')} {item['dest']}") + updated += 1 + continue + + dest.write_text(expected) + print(f" {color('✓', 'green')} Updated {item['dest']}") + updated += 1 + + return updated + + +def cmd_sync( + cwd: Path | None = None, + force: bool = False, + dry_run: bool = False, + hook_install: bool = False, +) -> int: + if cwd is None: + cwd = Path.cwd() + + if hook_install: + return _install_hook(cwd, dry_run=dry_run) + + if not has_source_code(cwd): + log_error("No source code found in current directory") + return EXIT_ERROR + + detection = detect_project_type(cwd) + insights = analyze(cwd) + project_type = detection.project_type.value if detection else "unknown" + + print(f"{color('⟳', 'blue')} Checking AI context freshness...") + print(f" Project: {cwd.name} ({project_type})") + print() + + drifted = _detect_drift(cwd, detection, insights) + + if not drifted: + print(f" {color('✓', 'green')} All AI context files are up to date.") + return EXIT_SUCCESS + + _print_drift(drifted) + print() + + if not force: + count = len(drifted) + msg = f"Update {count} stale file(s)? " + if not confirm_or_exit(msg): + print("Cancelled.") + return EXIT_ERROR + + updated = _regenerate(drifted, force=force, dry_run=dry_run) + if not dry_run: + log_info(f"Updated {updated}/{len(drifted)} file(s)") + else: + print(f" Would update {updated}/{len(drifted)} file(s)") + + return EXIT_SUCCESS + + +def _install_hook(cwd: Path, dry_run: bool = False) -> int: + git_hooks = cwd / ".git" / "hooks" + hook_path = git_hooks / "pre-commit" + + if not git_hooks.exists(): + log_error("Not a git repository (no .git/hooks directory)") + return EXIT_ERROR + + if hook_path.exists() and not dry_run: + msg = f"Overwrite existing {hook_path}?" + if not confirm_or_exit(msg): + print("Cancelled.") + return EXIT_ERROR + + if dry_run: + print(f" {color('Would install:', 'dim')} {hook_path}") + return EXIT_SUCCESS + + hook_path.parent.mkdir(parents=True, exist_ok=True) + hook_path.write_text(HOOK_TEMPLATE) + hook_path.chmod(0o755) + print(f" {color('✓', 'green')} Installed pre-commit hook: {hook_path}") + return EXIT_SUCCESS diff --git a/src/acorn/generators/builtin.py b/src/acorn/generators/builtin.py index c64db13..59b8132 100644 --- a/src/acorn/generators/builtin.py +++ b/src/acorn/generators/builtin.py @@ -348,50 +348,84 @@ def _generate_cursorrules( lines = ["You are an expert AI coding assistant specialized in this project.", ""] - if detection and hasattr(detection, "framework") and detection.framework: - lines.append(f"# Project Overview") - lines.append(f"This is a {detection.framework} ({project_type}) project.") + lines.append("# Project Overview") + fw = detection.framework if detection and hasattr(detection, "framework") and detection.framework else None + if fw: + lines.append(f"This is a {fw} ({pt}) project.") else: - lines.append(f"# Project Overview") - lines.append(f"This is a {project_type} project.") - - lines.append("") - - if detection and hasattr(detection, "matched_template") and detection.matched_template: - lines.append(f"Template: {detection.matched_template}") - + lines.append(f"This is a {pt} project.") lines.append("") - lines.append(f"# Tech Stack") - lines.append(f"- Language: {pt}") - if detection and hasattr(detection, "framework") and detection.framework: - lines.append(f"- Framework: {detection.framework}") + if insights: + arch = getattr(insights, "architecture_pattern", None) + if arch: + lines.append(f"This project uses the **{arch}** architecture pattern.") + lines.append("") + lines.append("## Tech Stack") + lines.append(f"- **Language**: {pt}") + if fw: + lines.append(f"- **Framework**: {fw}") if insights: + if insights.state_management: + lines.append(f"- **State Management**: {insights.state_management}") + if insights.styling_approach: + lines.append(f"- **Styling**: {insights.styling_approach}") if insights.orm: - lines.append(f"- ORM: {insights.orm}") + lines.append(f"- **Database/ORM**: {insights.orm}") if insights.test_runner: - lines.append(f"- Test: {insights.test_runner}") + lines.append(f"- **Testing**: {insights.test_runner}") if insights.bundler: - lines.append(f"- Bundler: {insights.bundler}") - + lines.append(f"- **Bundler**: {insights.bundler}") + if insights.api_style: + lines.append(f"- **API Style**: {insights.api_style}") + if insights.auth_lib: + lines.append(f"- **Auth**: {insights.auth_lib}") + if insights.package_manager: + lines.append(f"- **Package Manager**: {insights.package_manager}") lines.append("") - lines.append(f"# Conventions") + + if insights: + im = getattr(insights, "import_style", None) + ms = getattr(insights, "module_system", None) + nc = getattr(insights, "naming_convention", None) + if im and im != "unknown": + lines.append(f"- **Import Style**: {im}") + if ms and ms != "unknown": + lines.append(f"- **Module System**: {ms}") + if nc and nc != "unknown": + lines.append(f"- **Naming Convention**: {nc}") + if im or ms or (nc and nc != "unknown"): + lines.append("") + + if insights and hasattr(insights, "directory_purposes") and insights.directory_purposes: + lines.append("## Project Structure") + for d, purpose in sorted(insights.directory_purposes.items()): + colored = f"**`{d}/`** — {purpose}" + lines.append(f"- {colored}") + lines.append("") + + if insights and hasattr(insights, "api_route_paths") and insights.api_route_paths: + lines.append("## API Routes") + for route in sorted(insights.api_route_paths): + lines.append(f"- `{route}`") + lines.append("") + + lines.append("## Conventions") for c in conventions: lines.append(f"- {c}") - lines.append("") - lines.append(f"# Common Commands") + + lines.append("## Common Commands") for cmd in commands: lines.append(f"- `{cmd}`") - lines.append("") - lines.append(f"# Environment") + + lines.append("## Environment") for env_name, env_val in ENV_VARS.get(pt, []): lines.append(f"- `{env_name}`: {env_val}") - lines.append("") - lines.append(f"# Port") + lines.append(f"The application runs on port {dev_port}") lines.append("") @@ -409,33 +443,55 @@ def _generate_claude_md( commands = COMMON_COMMANDS.get(pt, []) lines = ["# Project", ""] - if detection and hasattr(detection, "framework") and detection.framework: - lines.append(f"This is a {detection.framework} ({project_type}) project.") - else: - lines.append(f"This is a {project_type} project.") - + fw = detection.framework if detection and hasattr(detection, "framework") and detection.framework else None + lines.append(f"This is a {fw if fw else pt} ({pt}) project.") lines.append("") + + if insights: + arch = getattr(insights, "architecture_pattern", None) + if arch: + lines.append(f"Architecture: {arch}") + lines.append("") + lines.append("## Tech Stack") lines.append(f"- **Language**: {pt}") - if detection and hasattr(detection, "framework") and detection.framework: - lines.append(f"- **Framework**: {detection.framework}") + if fw: + lines.append(f"- **Framework**: {fw}") if insights: + if insights.state_management: + lines.append(f"- **State Management**: {insights.state_management}") + if insights.styling_approach: + lines.append(f"- **Styling**: {insights.styling_approach}") if insights.orm: lines.append(f"- **ORM**: {insights.orm}") if insights.test_runner: lines.append(f"- **Test Runner**: {insights.test_runner}") - + if insights.bundler: + lines.append(f"- **Bundler**: {insights.bundler}") + if insights.api_style: + lines.append(f"- **API Style**: {insights.api_style}") + if insights.auth_lib: + lines.append(f"- **Auth**: {insights.auth_lib}") + if insights.package_manager: + lines.append(f"- **Package Manager**: {insights.package_manager}") lines.append("") + + if insights and hasattr(insights, "directory_purposes") and insights.directory_purposes: + lines.append("## Project Structure") + for d, purpose in sorted(insights.directory_purposes.items()): + lines.append(f"- `{d}/` — {purpose}") + lines.append("") + lines.append("## Conventions") for c in conventions: lines.append(f"- {c}") - lines.append("") + lines.append("## Commands") for cmd in commands: lines.append(f"- `{cmd}`") - lines.append("") + return "\n".join(lines) @@ -449,18 +505,38 @@ def _generate_copilot_instructions( conventions = ANTI_PATTERNS.get(pt, []) lines = ["# Project Context", ""] + fw = detection.framework if detection and hasattr(detection, "framework") and detection.framework else None lines.append(f"Language: {pt}") - if detection and hasattr(detection, "framework") and detection.framework: - lines.append(f"Framework: {detection.framework}") - if insights and insights.test_runner: - lines.append(f"Test: {insights.test_runner}") + if fw: + lines.append(f"Framework: {fw}") + if insights: + if insights.state_management: + lines.append(f"State Management: {insights.state_management}") + if insights.orm: + lines.append(f"ORM: {insights.orm}") + if insights.test_runner: + lines.append(f"Test: {insights.test_runner}") + if insights.api_style: + lines.append(f"API Style: {insights.api_style}") + if insights.bundler: + lines.append(f"Bundler: {insights.bundler}") + if insights.styling_approach: + lines.append(f"Styling: {insights.styling_approach}") + if insights.package_manager: + lines.append(f"Package Manager: {insights.package_manager}") + im = getattr(insights, "import_style", None) + nc = getattr(insights, "naming_convention", None) + if im and im != "unknown": + lines.append(f"Import Style: {im}") + if nc and nc != "unknown": + lines.append(f"Naming Convention: {nc}") lines.append("") lines.append("## Conventions") for c in conventions: lines.append(f"- {c}") - lines.append("") + return "\n".join(lines) diff --git a/tests/test_ast_lite.py b/tests/test_ast_lite.py new file mode 100644 index 0000000..2c5cbb6 --- /dev/null +++ b/tests/test_ast_lite.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from pathlib import Path + +from acorn.analysis import ast_lite +from acorn.analysis.insights import ProjectInsights, analyze + + +def test_detect_import_style_relative(tmp_path): + (tmp_path / "src").mkdir() + (tmp_path / "src" / "foo.ts").write_text('import { bar } from "../bar"') + result = ast_lite.detect_import_style(tmp_path) + assert result == "relative" + + +def test_detect_import_style_path_alias(tmp_path): + (tmp_path / "src").mkdir() + (tmp_path / "src" / "foo.ts").write_text('import { Button } from "@/components/button"') + result = ast_lite.detect_import_style(tmp_path) + assert result == "path-alias" + + +def test_detect_import_style_empty(tmp_path): + (tmp_path / "index.js").write_text("const x = 1") + result = ast_lite.detect_import_style(tmp_path) + assert result == "unknown" + + +def test_detect_module_system_esm(tmp_path): + (tmp_path / "app.js").write_text("import express from 'express'\nconst app = express()") + result = ast_lite.detect_module_system(tmp_path) + assert result == "esm" + + +def test_detect_module_system_commonjs(tmp_path): + (tmp_path / "app.js").write_text("const express = require('express')") + result = ast_lite.detect_module_system(tmp_path) + assert result == "commonjs" + + +def test_detect_module_system_package_json(tmp_path): + (tmp_path / "app.js").write_text("const x = 1") + (tmp_path / "package.json").write_text('{"type": "module"}') + result = ast_lite.detect_module_system(tmp_path) + assert result == "esm" + + +def test_detect_naming_convention_camelCase(tmp_path): + (tmp_path / "myComponent.ts").write_text("const x = 1") + (tmp_path / "useFoo.ts").write_text("const x = 1") + result = ast_lite.detect_naming_convention(tmp_path) + assert result == "camelCase" + + +def test_detect_naming_convention_kebab(tmp_path): + (tmp_path / "my-component.ts").write_text("const x = 1") + (tmp_path / "use-foo.ts").write_text("const x = 1") + result = ast_lite.detect_naming_convention(tmp_path) + assert result == "kebab-case" + + +def test_detect_state_management_zustand(tmp_path): + (tmp_path / "package.json").write_text('{"dependencies": {"zustand": "^4.0.0"}}') + result = ast_lite.detect_state_management(tmp_path) + assert result == "Zustand" + + +def test_detect_state_management_redux(tmp_path): + (tmp_path / "package.json").write_text('{"dependencies": {"@reduxjs/toolkit": "^2.0.0"}}') + result = ast_lite.detect_state_management(tmp_path) + assert result == "Redux Toolkit" + + +def test_detect_styling_tailwind(tmp_path): + (tmp_path / "tailwind.config.js").write_text("module.exports = {}") + result = ast_lite.detect_styling_approach(tmp_path) + assert result == "tailwindcss" + + +def test_detect_styling_css_modules(tmp_path): + (tmp_path / "src").mkdir() + (tmp_path / "src" / "style.module.css").write_text(".foo {}") + result = ast_lite.detect_styling_approach(tmp_path) + assert result == "css-modules" + + +def test_detect_api_style_trpc(tmp_path): + (tmp_path / "src").mkdir() + (tmp_path / "src" / "router.ts").write_text("trpc stuff") + (tmp_path / "package.json").write_text('{"dependencies": {"@trpc/server": "^10.0.0"}}') + result = ast_lite.detect_api_style(tmp_path) + assert result == "tRPC" + + +def test_detect_api_style_graphql(tmp_path): + (tmp_path / "schema.graphql").write_text("type Query { hello: String }") + result = ast_lite.detect_api_style(tmp_path) + assert result == "GraphQL" + + +def test_detect_api_style_rest(tmp_path): + route_file = tmp_path / "app" / "api" / "users" / "route.ts" + route_file.parent.mkdir(parents=True) + route_file.write_text("export function GET() {}") + (tmp_path / "package.json").write_text('{"name": "test"}') + result = ast_lite.detect_api_style(tmp_path) + assert result == "REST (file-based)" + + +def test_detect_architecture_app_router(tmp_path): + (tmp_path / "app").mkdir() + result = ast_lite.detect_architecture_pattern(tmp_path, "node") + assert result == "app-router" + + +def test_detect_architecture_monorepo(tmp_path): + (tmp_path / "pnpm-workspace.yaml").write_text("packages:\n - apps/*") + result = ast_lite.detect_architecture_pattern(tmp_path, "node") + assert result == "monorepo" + + +def test_detect_directory_purpose(tmp_path): + structure = { + "components": ["Button.tsx"], + "lib": ["utils.ts"], + "pages": ["index.tsx"], + } + result = ast_lite.detect_directory_purpose(structure, "node") + assert result["components"] == "Reusable React components" + assert result["lib"] == "Utility functions and shared logic" + + +def test_insights_new_fields(tmp_path): + (tmp_path / "package.json").write_text( + '{"dependencies": {"next": "^14.0.0", "zustand": "^4.0.0", "tailwindcss": "^3.0.0", "vitest": "^1.0.0", "prisma": "^5.0.0", "@trpc/server": "^10.0.0"}}' + ) + route_file = tmp_path / "app" / "api" / "users" / "route.ts" + route_file.parent.mkdir(parents=True) + route_file.write_text("import prisma from '@/lib/prisma'") + src_comp = tmp_path / "src" / "components" + src_comp.mkdir(parents=True) + (src_comp / "Button.tsx").write_text("export const Button = () => null") + src_lib = tmp_path / "src" / "lib" + src_lib.mkdir() + (src_lib / "utils.ts").write_text("export const x = 1") + result = analyze(tmp_path) + assert result.language == "node" + assert result.framework == "Next.js" + assert result.state_management == "Zustand" + assert result.test_runner == "vitest" + assert result.orm == "prisma" + assert result.api_style is not None + assert result.architecture_pattern is not None + assert result.directory_purposes diff --git a/tests/test_builtin.py b/tests/test_builtin.py index f481c34..d95e2bb 100644 --- a/tests/test_builtin.py +++ b/tests/test_builtin.py @@ -80,9 +80,50 @@ def test_gitignore_go(): assert "*.exe" in content -def test_cursorrules_has_tech_stack(): +def test_cursorrules_has_sections(): content = generate_file_content(".cursorrules", "node") - assert "Tech Stack" in content + assert "## Tech Stack" in content + assert "## Conventions" in content + assert "## Common Commands" in content + + +def test_cursorrules_with_insights_has_rich_content(): + class FakeInsights: + orm = "prisma" + test_runner = "vitest" + bundler = "vite" + state_management = "Zustand" + styling_approach = "tailwindcss" + api_style = "REST" + auth_lib = "next-auth" + package_manager = "pnpm" + import_style = "path-alias" + module_system = "esm" + naming_convention = "camelCase" + architecture_pattern = "app-router" + api_route_paths = ["/api/users", "/api/posts"] + directory_purposes = { + "components": "Reusable React components", + "lib": "Utility functions", + "app": "Route handlers and pages", + } + src_structure = {} + + class FakeDetection: + project_type = type("pt", (), {"value": "node"})() + framework = "Next.js" + matched_template = "node-api" + confidence = 0.95 + + content = generate_file_content(".cursorrules", "node", insights=FakeInsights(), detection=FakeDetection()) + assert "Zustand" in content + assert "tailwindcss" in content + assert "app-router" in content + assert "path-alias" in content + assert "## Project Structure" in content + assert "## API Routes" in content + assert "/api/users" in content + assert "Reusable React components" in content def test_claude_md_has_sections(): @@ -92,12 +133,72 @@ def test_claude_md_has_sections(): assert "## Conventions" in content +def test_claude_md_with_insights(): + class FakeInsights: + orm = "sqlalchemy" + test_runner = "pytest" + bundler = None + state_management = None + styling_approach = None + api_style = None + auth_lib = None + package_manager = "pip" + import_style = "unknown" + module_system = "unknown" + naming_convention = "snake_case" + architecture_pattern = None + api_route_paths = [] + directory_purposes = {"api": "API route handlers", "models": "Data models"} + src_structure = {} + + class FakeDetection: + project_type = type("pt", (), {"value": "python"})() + framework = "FastAPI" + matched_template = "python-fastapi" + confidence = 0.95 + + content = generate_file_content("CLAUDE.md", "python", insights=FakeInsights(), detection=FakeDetection()) + assert "Project Structure" in content + assert "API route handlers" in content + + def test_copilot_instructions(): content = generate_file_content(".github/copilot-instructions.md", "go") assert "# Project Context" in content assert "Language:" in content +def test_copilot_instructions_with_insights(): + class FakeInsights: + orm = "prisma" + test_runner = "vitest" + bundler = "vite" + state_management = "Zustand" + styling_approach = "tailwindcss" + api_style = "tRPC" + auth_lib = "next-auth" + package_manager = "pnpm" + import_style = "path-alias" + module_system = "esm" + naming_convention = "camelCase" + architecture_pattern = None + api_route_paths = [] + directory_purposes = {} + src_structure = {} + + class FakeDetection: + project_type = type("pt", (), {"value": "node"})() + framework = "Next.js" + matched_template = "node-api" + confidence = 0.95 + + content = generate_file_content(".github/copilot-instructions.md", "node", insights=FakeInsights(), detection=FakeDetection()) + assert "tRPC" in content + assert "path-alias" in content + assert "camelCase" in content + assert "Import Style:" in content + + def test_unknown_file_type_raises(): try: generate_file_content("nonexistent", "node") @@ -136,7 +237,6 @@ class FakeDetection: content = generate_file_content(".cursorrules", "node", detection=FakeDetection()) assert "Express" in content - assert "node-api" in content def test_generate_file_content_with_insights(): @@ -144,6 +244,18 @@ class FakeInsights: orm = "prisma" test_runner = "vitest" bundler = "vite" + state_management = None + styling_approach = None + api_style = None + auth_lib = None + package_manager = "pnpm" + import_style = "path-alias" + module_system = "esm" + naming_convention = "camelCase" + architecture_pattern = None + api_route_paths = [] + directory_purposes = {} + src_structure = {} content = generate_file_content(".cursorrules", "node", insights=FakeInsights()) assert "prisma" in content diff --git a/tests/test_check_update.py b/tests/test_check_update.py index f68ba30..85431df 100644 --- a/tests/test_check_update.py +++ b/tests/test_check_update.py @@ -27,7 +27,7 @@ def test_check_update_upgrade_available(mock_urlopen): def test_check_update_current(mock_urlopen): mock_resp = MagicMock() mock_resp.read.return_value = json.dumps({ - "info": {"version": "0.2.0"} + "info": {"version": "0.3.0"} }).encode("utf-8") mock_resp.__enter__.return_value = mock_resp mock_urlopen.return_value = mock_resp @@ -35,7 +35,7 @@ def test_check_update_current(mock_urlopen): result = check_pypi_version(offline=False) assert result is not None assert result["upgrade_available"] is False - assert result["latest"] == "0.2.0" + assert result["latest"] == "0.3.0" @patch("acorn.check_update.urllib.request.urlopen") diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..d39c594 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from acorn.commands.sync import SYNC_TARGETS, _detect_drift, _install_hook, cmd_sync + + +def test_sync_detects_drift(tmp_path): + (tmp_path / "package.json").write_text('{"name": "test"}') + (tmp_path / ".cursorrules").write_text("old stale content") + detection_cls = type("Detection", (), { + "project_type": type("PT", (), {"value": "node"})(), + "framework": "Express", + "matched_template": "node-api", + "confidence": 0.9, + }) + insights_cls = type("Insights", (), { + "orm": None, "test_runner": None, "bundler": None, + "state_management": None, "styling_approach": None, + "api_style": None, "auth_lib": None, + "package_manager": None, + "import_style": "unknown", "module_system": "unknown", + "naming_convention": "unknown", + "architecture_pattern": None, + "api_route_paths": [], "directory_purposes": {}, + "src_structure": {}, + }) + detection = detection_cls() + insights = insights_cls() + drifted = _detect_drift(tmp_path, detection, insights) + assert len(drifted) >= 1 + + +def test_sync_no_drift(tmp_path): + (tmp_path / "package.json").write_text('{"name": "test"}') + detection_cls = type("Detection", (), { + "project_type": type("PT", (), {"value": "node"})(), + "framework": "Express", + "matched_template": "node-api", + "confidence": 0.9, + }) + insights_cls = type("Insights", (), { + "orm": None, "test_runner": None, "bundler": None, + "state_management": None, "styling_approach": None, + "api_style": None, "auth_lib": None, + "package_manager": None, + "import_style": "unknown", "module_system": "unknown", + "naming_convention": "unknown", + "architecture_pattern": None, + "api_route_paths": [], "directory_purposes": {}, + "src_structure": {}, + }) + detection = detection_cls() + insights = insights_cls() + + from acorn.generators.builtin import generate_file_content + expected = generate_file_content(".cursorrules", "node", detection=detection, insights=insights) + (tmp_path / ".cursorrules").write_text(expected) + + drifted = _detect_drift(tmp_path, detection, insights) + assert len(drifted) == 0 + + +def test_sync_regenerates_stale(tmp_path): + (tmp_path / "package.json").write_text('{"name": "test"}') + (tmp_path / ".cursorrules").write_text("stale") + + with patch("acorn.commands.sync.detect_project_type") as mock_detect: + from acorn.models import DetectionResult, ProjectType + mock_detect.return_value = DetectionResult( + project_type=ProjectType.NODE, matched_template="node-api", + confidence=0.9, + ) + with patch("acorn.commands.sync.analyze") as mock_analyze: + mock_analyze.return_value = type("Insights", (), { + "orm": None, "test_runner": None, "bundler": None, + "state_management": None, "styling_approach": None, + "api_style": None, "auth_lib": None, + "package_manager": None, + "import_style": "unknown", "module_system": "unknown", + "naming_convention": "unknown", + "architecture_pattern": None, + "api_route_paths": [], "directory_purposes": {}, + "src_structure": {}, + })() + with patch("acorn.commands.sync.has_source_code", return_value=True): + rc = cmd_sync(cwd=tmp_path, force=True) + assert rc == 0 + content = (tmp_path / ".cursorrules").read_text() + assert content != "stale" + + +def test_sync_all_up_to_date(tmp_path): + (tmp_path / "package.json").write_text('{"name": "test"}') + from acorn.generators.builtin import generate_file_content + detection_cls = type("Detection", (), { + "project_type": type("PT", (), {"value": "node"})(), + "framework": "Express", + "matched_template": "node-api", + "confidence": 0.9, + }) + insights_cls = type("Insights", (), { + "orm": None, "test_runner": None, "bundler": None, + "state_management": None, "styling_approach": None, + "api_style": None, "auth_lib": None, + "package_manager": None, + "import_style": "unknown", "module_system": "unknown", + "naming_convention": "unknown", + "architecture_pattern": None, + "api_route_paths": [], "directory_purposes": {}, + "src_structure": {}, + }) + detection = detection_cls() + insights = insights_cls() + expected = generate_file_content(".cursorrules", "node", detection=detection, insights=insights) + (tmp_path / ".cursorrules").write_text(expected) + + with patch("acorn.commands.sync.detect_project_type", return_value=detection): + with patch("acorn.commands.sync.analyze", return_value=insights): + with patch("acorn.commands.sync.has_source_code", return_value=True): + rc = cmd_sync(cwd=tmp_path, force=True) + assert rc == 0 + + +def test_sync_no_source_code(tmp_path): + rc = cmd_sync(cwd=tmp_path, force=True) + assert rc != 0 + + +def test_sync_dry_run(tmp_path): + (tmp_path / "package.json").write_text('{"name": "test"}') + (tmp_path / ".cursorrules").write_text("stale") + + with patch("acorn.commands.sync.detect_project_type") as mock_detect: + from acorn.models import DetectionResult, ProjectType + mock_detect.return_value = DetectionResult( + project_type=ProjectType.NODE, matched_template="node-api", + confidence=0.9, + ) + with patch("acorn.commands.sync.analyze") as mock_analyze: + mock_analyze.return_value = type("Insights", (), { + "orm": None, "test_runner": None, "bundler": None, + "state_management": None, "styling_approach": None, + "api_style": None, "auth_lib": None, + "package_manager": None, + "import_style": "unknown", "module_system": "unknown", + "naming_convention": "unknown", + "architecture_pattern": None, + "api_route_paths": [], "directory_purposes": {}, + "src_structure": {}, + })() + with patch("acorn.commands.sync.has_source_code", return_value=True): + rc = cmd_sync(cwd=tmp_path, force=True, dry_run=True) + assert rc == 0 + content = (tmp_path / ".cursorrules").read_text() + assert content == "stale" + + +def test_install_hook(tmp_path): + git_hooks = tmp_path / ".git" / "hooks" + git_hooks.mkdir(parents=True) + rc = _install_hook(tmp_path) + assert rc == 0 + hook = git_hooks / "pre-commit" + assert hook.exists() + assert hook.stat().st_mode & 0o111 + + +def test_install_hook_no_git(tmp_path): + rc = _install_hook(tmp_path) + assert rc != 0 + + +def test_install_hook_dry_run(tmp_path): + git_hooks = tmp_path / ".git" / "hooks" + git_hooks.mkdir(parents=True) + rc = _install_hook(tmp_path, dry_run=True) + assert rc == 0 + hook = git_hooks / "pre-commit" + assert not hook.exists() + + +def test_sync_detects_multiple_drift(tmp_path): + (tmp_path / "package.json").write_text('{"name": "test"}') + (tmp_path / ".cursorrules").write_text("stale cursor") + (tmp_path / "CLAUDE.md").write_text("stale claude") + (tmp_path / ".github" / "copilot-instructions.md").parent.mkdir(parents=True) + (tmp_path / ".github" / "copilot-instructions.md").write_text("stale copilot") + + detection_cls = type("Detection", (), { + "project_type": type("PT", (), {"value": "node"})(), + "framework": "Express", + "matched_template": "node-api", + "confidence": 0.9, + }) + insights_cls = type("Insights", (), { + "orm": None, "test_runner": None, "bundler": None, + "state_management": None, "styling_approach": None, + "api_style": None, "auth_lib": None, + "package_manager": None, + "import_style": "unknown", "module_system": "unknown", + "naming_convention": "unknown", + "architecture_pattern": None, + "api_route_paths": [], "directory_purposes": {}, + "src_structure": {}, + }) + drifted = _detect_drift(tmp_path, detection_cls(), insights_cls()) + assert len(drifted) == 3