From 373ddfc638140e39881da4f463e656129bfd0268 Mon Sep 17 00:00:00 2001 From: "Gabriel.Foo" Date: Fri, 15 May 2026 22:35:40 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Phase=201=20=E2=80=94=20pivot=20to?= =?UTF-8?q?=20AI=20coding=20environment=20optimizer=20(doctor=20+=20fix=20?= =?UTF-8?q?+=20builtin=20generators)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Doctor: 7-check health report with auto-fix prompt - Fix: targeted file generation (dockerfile, ai, gitignore, etc.) via unified builtin.py - Builtin: 9-language conventions for Dockerfile, docker-compose, .gitignore, .dockerignore, .cursorrules, CLAUDE.md, copilot-instructions.md - CLI: acorn no-args → doctor (if source) or wizard (if empty) - i18n: bilingual doctor/fix messages - 577 tests, 86% coverage --- README.md | 310 +++++----- pyproject.toml | 4 +- src/acorn/__init__.py | 2 +- src/acorn/_compat.py | 12 + src/acorn/analysis/__init__.py | 0 src/acorn/analysis/detector.py | 341 +++++++++++ src/acorn/analysis/health.py | 101 ++++ src/acorn/analysis/health_rules.py | 45 ++ src/acorn/analysis/insights.py | 352 ++++++++++++ src/acorn/cli.py | 887 ++--------------------------- src/acorn/commands/__init__.py | 0 src/acorn/commands/admin.py | 157 +++++ src/acorn/commands/analyze_cmd.py | 57 ++ src/acorn/commands/clean.py | 64 +++ src/acorn/commands/docker.py | 145 +++++ src/acorn/commands/doctor.py | 87 +++ src/acorn/commands/fix.py | 115 ++++ src/acorn/commands/generate.py | 197 +++++++ src/acorn/commands/marketplace.py | 48 ++ src/acorn/commands/template_cmd.py | 169 ++++++ src/acorn/detector.py | 374 ++---------- src/acorn/format.py | 37 ++ src/acorn/generators/__init__.py | 0 src/acorn/generators/builtin.py | 494 ++++++++++++++++ src/acorn/locales/en.yaml | 47 ++ src/acorn/locales/zh.yaml | 47 ++ src/acorn/template_engine.py | 94 +-- src/acorn/wizard.py | 2 +- tests/test_builtin.py | 150 +++++ tests/test_check_update.py | 4 +- tests/test_cli.py | 34 +- tests/test_detector.py | 8 +- tests/test_doctor.py | 163 ++++++ tests/test_fix.py | 159 ++++++ tests/test_json_output.py | 16 + tests/test_template_engine.py | 143 ++--- tests/test_wizard_and_tools.py | 20 +- 37 files changed, 3292 insertions(+), 1593 deletions(-) create mode 100644 src/acorn/analysis/__init__.py create mode 100644 src/acorn/analysis/detector.py create mode 100644 src/acorn/analysis/health.py create mode 100644 src/acorn/analysis/health_rules.py create mode 100644 src/acorn/analysis/insights.py create mode 100644 src/acorn/commands/__init__.py create mode 100644 src/acorn/commands/admin.py create mode 100644 src/acorn/commands/analyze_cmd.py create mode 100644 src/acorn/commands/clean.py create mode 100644 src/acorn/commands/docker.py create mode 100644 src/acorn/commands/doctor.py create mode 100644 src/acorn/commands/fix.py create mode 100644 src/acorn/commands/generate.py create mode 100644 src/acorn/commands/marketplace.py create mode 100644 src/acorn/commands/template_cmd.py create mode 100644 src/acorn/format.py create mode 100644 src/acorn/generators/__init__.py create mode 100644 src/acorn/generators/builtin.py create mode 100644 tests/test_builtin.py create mode 100644 tests/test_doctor.py create mode 100644 tests/test_fix.py create mode 100644 tests/test_json_output.py diff --git a/README.md b/README.md index 1366b61..f7a9b0b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,49 @@ # Acorn -智能项目初始化工具 — 自动检测项目类型、匹配模板、生成 Docker/devcontainer 配置。 +AI 编码环境优化工具 — 自动检测项目、诊断缺失配置、一键修复。 -Smart project initializer — auto-detects project types, matches templates, generates Docker/devcontainer config files. +AI coding environment optimizer — auto-detect, diagnose, and fix your project setup. + +--- + +## 新能力 / What's New + +```bash +# 一键诊断 + 修复 / Diagnose & fix in one go +cd my-project +acorn + +# 诊断模式 — 查看项目健康状态 / Check project health +acorn --json # JSON 输出供脚本使用 + +# 自定义修复 / Targeted fixes +acorn fix --dockerfile # 只生成 Dockerfile +acorn fix --docker-compose # 只生成 docker-compose.yml +acorn fix --dockerignore # 只生成 .dockerignore +acorn fix --gitignore # 只生成 .gitignore +acorn fix --cursorrules # 只生成 Cursor AI 规则 +acorn fix --claude-md # 只生成 CLAUDE.md +acorn fix --copilot # 只生成 Copilot 指令 +acorn fix --ai # 生成所有 AI 配置文件 +acorn fix --all # 全部修复 + +# 兼容旧用法 / Legacy shortcut +acorn --dockerize # 等价于 acorn fix --dockerfile +``` + +支持 9 种语言 / Supports 9 languages: + +| 语言 | 检测文件 | Docker 基础镜像 | +|------|----------|----------------| +| Node.js | `package.json`, `index.js` | `node:20-alpine` | +| Python | `requirements.txt`, `pyproject.toml` | `python:3.12-slim` | +| Go | `go.mod`, `main.go` | `golang:1.22-alpine` | +| Rust | `Cargo.toml`, `main.rs` | `rust:1.78-slim` | +| Java | `pom.xml`, `build.gradle` | `eclipse-temurin:21-jdk` | +| Ruby | `Gemfile`, `Gemfile.lock` | `ruby:3.3-alpine` | +| PHP | `composer.json`, `index.php` | `php:8.3-cli` | +| Deno | `deno.json`, `deno.jsonc` | `denoland/deno:latest` | +| Bun | `bun.lockb`, `bunfig.toml` | `oven/bun:latest` | --- @@ -26,25 +67,18 @@ pip install acorn[all] # 全部 / all extras ## Quick Start / 快速开始 ```bash -# 自动检测当前目录并生成配置 / Auto-detect and generate +# 进入项目目录,自动诊断 +cd my-node-project acorn +# → 诊断报告 + 提示是否自动修复 -# 指定目标目录 / Specify target directory +# 指定目标目录 acorn --dir /path/to/project -# 列出可用模板 / List available templates -acorn --list - -# 从指定模板生成 / Generate from a template -acorn --template python-fastapi - -# 交互式选择 / Interactive mode -acorn --interactive - -# 中文模式 / Chinese language mode +# 中文模式 acorn --lang zh -# 预览不执行 / Dry run (preview without writing) +# 预览不执行 acorn --dry-run ``` @@ -52,157 +86,97 @@ acorn --dry-run ## Usage / 使用说明 -### 核心选项 / Core Options - -| 选项 / Flag | 说明 / Description | -|-------------|-------------------| -| `--dir, -d DIR` | 目标项目目录(默认当前目录)/ Target directory (default: `.`) | -| `--template, -t NAME` | 指定模板 / Specify template by name | -| `--list, -l` | 列出可用模板 / List available templates | -| `--add PATH` | 添加自定义模板目录 / Add custom template directory | -| `--remove NAME` | 删除已安装模板 / Remove installed template | -| `--init` | 在项目中创建 `.acorn/config.yaml` / Create project config | - -### 生成选项 / Generation Options - -| 选项 / Flag | 说明 / Description | -|-------------|-------------------| -| `--interactive, -i` | 交互式配置 / Interactive configuration | -| `--force, -f` | 强制覆盖已有文件 / Overwrite existing files | -| `--regenerate, -r` | 重新生成(自动备份原文件)/ Regenerate (auto-backup) | -| `--dry-run, -n` | 预览不执行 / Preview without writing | -| `--var, -v KEY=VALUE` | 自定义模板变量,可多次使用 / Set template variables (repeatable) | -| `--save` | 生成后保存为新模板 / Save output as new template | -| `--save-as NAME` | 指定名称保存为新模板 / Save as new template with name | - -### 市场 / Marketplace - -| 选项 / Flag | 说明 / Description | -|-------------|-------------------| -| `--search QUERY` | 搜索社区模板 / Search community templates on GitHub | -| `--install REPO` | 安装模板 (`user/repo`) / Install template from GitHub | - -### 管理 / Administration - -| 选项 / Flag | 说明 / Description | -|-------------|-------------------| -| `--check-update` | 检查 PyPI 版本更新 / Check for new version on PyPI | -| `--export [FILE]` | 导出项目配置 / Export project config | -| `--import FILE` | 导入项目配置 / Import project config | -| `--scan PATH` | 扫描安全问题 / Scan for security issues | -| `--config FILE` | 指定全局配置文件 / Use custom config file | - -### 全局选项 / Global Options - -| 选项 / Flag | 说明 / Description | -|-------------|-------------------| -| `--lang LANG` | 语言 `en` 或 `zh` / Language | -| `--verbose` | 详细输出 / Verbose output | -| `--debug` | 调试模式 / Debug mode | -| `--quiet` | 静默模式(仅错误)/ Silent (errors only) | -| `--offline` | 离线模式 / Offline mode | -| `--version` | 显示版本 / Show version | - ---- +### 诊断 / Doctor -## Examples / 示例 +`acorn` (无参数) = 诊断模式。检测 7 项检查: -```bash -# 自动检测并生成 / Auto-detect and generate -cd my-node-project -acorn +| 检查项 | 类别 | 说明 | +|--------|------|------| +| `.gitignore` | DevOps | Git 忽略规则 | +| `Dockerfile` | DevOps | 容器化配置 | +| `docker-compose.yml` | DevOps | 本地开发编排 | +| `.cursorrules` | AI 就绪 | Cursor AI 项目规则 | +| `CLAUDE.md` | AI 就绪 | Claude Code 上下文 | +| `.github/copilot-instructions.md` | AI 就绪 | GitHub Copilot 指令 | +| `.editorconfig` | 代码质量 | 编辑器统一配置 | -# 指定模板和自定义变量 / Template with custom variables -acorn --template python-fastapi --var port=8080 --var app_name=myservice +### 修复 / Fix -# 交互式模式 / Interactive mode -acorn --interactive +```bash +# 子命令模式 +acorn fix --dockerfile +acorn fix --ai +acorn fix --all --force # 强制覆盖已有文件 +acorn fix --all --dry-run # 预览 -# 预览不执行 / Dry run to preview changes -acorn --dry-run +# 或通过无参数诊断后的交互提示自动修复 +acorn +``` -# 强制覆盖 / Force overwrite -acorn --force +### 生成 / Generate -# 安全扫描 / Security scan -acorn --scan ./my-template +```bash +# 从模板生成项目配置 +acorn --template python-fastapi -# 搜索和安装社区模板 / Search and install community templates -acorn --search fastapi -acorn --install SilasFu/init-template-fastapi +# 交互式选择 +acorn --interactive -# 导出和导入配置 / Export and import project config -acorn --export my-config.yaml -acorn --import my-config.yaml +# 组合多个模板 +acorn --with node-api,react-vite -# 中文模式 / Chinese language mode -acorn --lang zh +# 列出可用模板 +acorn --list ``` ---- - -## Project Config / 项目配置 - -创建项目级配置来锁定模板 / Lock template selection for a project: +### 向导 / Wizard ```bash -acorn --init --template python-fastapi +acorn --wizard +# 或:空目录下直接 acorn 会自动启动向导 ``` -这会在项目中创建 `.acorn/config.yaml`,后续运行将自动使用该模板。 - -This creates `.acorn/config.yaml` in the project directory. The template will be used automatically on subsequent runs. - --- -## Templates / 模板 +## Examples / 示例 -### 内置模板 / Built-in Templates +```bash +# 进入 Python 项目,自动诊断 +cd my-python-app +acorn +# → 诊断报告: +# ✓ .gitignore 已存在 +# ✗ Dockerfile [acorn fix --dockerfile] +# ✗ .cursorrules [acorn fix --cursorrules] +# 是否修复所有可修复项?[Y/n] -| 模板 / Template | 说明 / Description | -|----------------|-------------------| -| `node-api` | Node.js Express API | -| `python-fastapi` | Python FastAPI service | -| `react-vite` | React + Vite frontend | -| `golang-gin` | Go Gin web framework | -| `java-spring` | Java Spring Boot | -| `ruby-rails` | Ruby on Rails | -| `php-laravel` | PHP Laravel | -| `deno-app` | Deno application | -| `bun-app` | Bun application | -| `rust-app` | Rust application (via cargo) | +# 指定语言修复 +cd go-project +acorn fix --dockerfile --gitignore --force -### 模板开发 / Template Development +# JSON 输出 +acorn --json +# → {"project": "/path", "type": "node", "checks": [...], "summary": {...}} -模板通过 `template.yaml` 定义 / Templates are defined with a `template.yaml`: - -```yaml -name: my-template -description: 我的自定义模板 / My custom template -version: 1.0.0 -type: python -files: - - Dockerfile - - docker-compose.yml - - .env.example -variables: - - name: port - default: "8000" - prompt: "应用端口 / Application port" - - name: app_name - default: "myapp" -detectors: - files: - - requirements.txt - - pyproject.toml - keywords: - - fastapi - - django +# 中文模式 +acorn --lang zh ``` -模板文件放在 `files/` 子目录中,支持 Jinja2 模板语法。 +--- -Template files go in the `files/` subdirectory, supporting Jinja2 template syntax. +## Detection / 项目检测 + +| 语言 | 检测文件 | +|------|----------| +| **Node.js** | `package.json`, `index.js`, `app.js`, `server.js` | +| **Python** | `requirements.txt`, `pyproject.toml`, `setup.py`, `main.py`, `app.py` | +| **Go** | `go.mod`, `main.go` | +| **Java** | `pom.xml`, `build.gradle` | +| **Ruby** | `Gemfile`, `Gemfile.lock` | +| **PHP** | `composer.json`, `index.php` | +| **Rust** | `Cargo.toml`, `main.rs` | +| **Deno** | `deno.json`, `deno.jsonc`, `main.ts`, `main.js` | +| **Bun** | `bun.lockb`, `bunfig.toml` | --- @@ -224,59 +198,31 @@ offline: false Python 插件放在 `~/.config/acorn/plugins/`: -Place Python plugins in `~/.config/acorn/plugins/`: - ```python def before_detect(context): - # 修改检测上下文 / Modify detection context return context def after_generate(context): - # 生成后钩子 / Post-generation hook return context ``` ### 自定义命令 / Custom Commands -可执行脚本放在 `~/.config/acorn/commands/`,会自动注册为 CLI 子命令。 - -Place executable scripts in `~/.config/acorn/commands/` — they are automatically registered as CLI commands. - ---- - -## Detection / 项目检测 - -Acorn 内置了以下语言的检测规则 / Built-in detection rules: +可执行脚本放在 `~/.config/acorn/commands/`,自动注册为 CLI 子命令。 -| 语言 / Language | 检测文件 / Detection Files | -|----------------|--------------------------| -| **Node.js** | `package.json`, `index.js`, `app.js`, `server.js` | -| **Python** | `requirements.txt`, `pyproject.toml`, `setup.py`, `main.py`, `app.py` | -| **Go** | `go.mod`, `main.go` | -| **Java** | `pom.xml`, `build.gradle`, `pom.xml` | -| **Ruby** | `Gemfile`, `Gemfile.lock` | -| **PHP** | `composer.json`, `index.php` | -| **Rust** | `Cargo.toml`, `main.rs` | -| **Deno** | `deno.json`, `deno.jsonc`, `main.ts`, `main.js` | -| **Bun** | `bun.lockb`, `bunfig.toml` | - ---- - -## Auto-generation / 自动生成 - -当没有匹配模板时,Acorn 会根据项目类型自动生成以下文件: - -When no template matches, Acorn auto-generates these files based on project type: +### 模板开发 / Template Development -| 文件 / File | 说明 / Description | -|------------|-------------------| -| `Dockerfile` | 多阶段 Docker 构建 / Multi-stage Docker build | -| `docker-compose.yml` | Docker Compose 配置 / Docker Compose config | -| `.gitignore` | 按语言优化的 .gitignore / Language-optimized .gitignore | -| `.devcontainer/devcontainer.json` | VS Code Dev Container 配置 | -| `Makefile` | 常用命令快捷方式 / Common command shortcuts | -| `.nvmrc` | Node.js 版本锁定(仅 Node)/ Node version pinning (Node only) | -| `.env.example` | 环境变量示例 / Environment variable example | +```yaml +# template.yaml +name: my-template +description: Python FastAPI service +version: 1.0.0 +type: python +files: + - Dockerfile + - docker-compose.yml + - .env.example +``` --- diff --git a/pyproject.toml b/pyproject.toml index 214d276..4f7fa91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "acorn" -version = "0.1.0" +version = "0.2.0" description = "智能项目初始化工具 - 自动检测项目类型、匹配模板、生成配置" readme = "README.md" license = "MIT" @@ -45,7 +45,7 @@ acorn = "acorn.cli:main" [project.optional-dependencies] color = ["colorama"] network = ["requests"] -advanced = ["jinja2"] +advanced = ["jinja2", "tomli>=2.0"] dev = ["pytest>=7", "pytest-cov>=4", "ruff>=0.1"] [tool.hatch.build.targets.wheel] diff --git a/src/acorn/__init__.py b/src/acorn/__init__.py index 3dc1f76..d3ec452 100644 --- a/src/acorn/__init__.py +++ b/src/acorn/__init__.py @@ -1 +1 @@ -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/src/acorn/_compat.py b/src/acorn/_compat.py index 37a687d..f16d704 100644 --- a/src/acorn/_compat.py +++ b/src/acorn/_compat.py @@ -10,3 +10,15 @@ def resource_path(relative: str) -> Path: else: base = Path(__file__).resolve().parent return base / relative + + +# ── tomllib compatibility (Python 3.10) ── +if sys.version_info >= (3, 11): + import tomllib +else: + try: + import tomli as tomllib # type: ignore[no-redef] + except ImportError: + tomllib = None # type: ignore[assignment] + +__all__ = ["resource_path", "tomllib"] diff --git a/src/acorn/analysis/__init__.py b/src/acorn/analysis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/acorn/analysis/detector.py b/src/acorn/analysis/detector.py new file mode 100644 index 0000000..5f31128 --- /dev/null +++ b/src/acorn/analysis/detector.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +import re +from pathlib import Path + +import yaml + +from acorn.config import ( + load_detector_rules, + load_templates, + resolve_template, +) +from acorn.models import ( + DetectionResult, + DetectorRule, + ProjectType, + Template, +) + +IGNORE_DIRS = { + ".git", "node_modules", "__pycache__", ".venv", "venv", + "target", "build", "dist", ".next", ".nuxt", + ".idea", ".vscode", ".DS_Store", +} + +MANIFEST_MAP: dict[str, ProjectType] = { + "package.json": ProjectType.NODE, + "pyproject.toml": ProjectType.PYTHON, + "requirements.txt": ProjectType.PYTHON, + "setup.py": ProjectType.PYTHON, + "setup.cfg": ProjectType.PYTHON, + "Pipfile": ProjectType.PYTHON, + "go.mod": ProjectType.GO, + "Cargo.toml": ProjectType.RUST, + "pom.xml": ProjectType.JAVA, + "build.gradle": ProjectType.JAVA, + "build.gradle.kts": ProjectType.JAVA, + "Gemfile": ProjectType.RUBY, + "composer.json": ProjectType.PHP, + "deno.json": ProjectType.DENO, + "deno.jsonc": ProjectType.DENO, + "bun.lockb": ProjectType.BUN, + "bun.lock": ProjectType.BUN, +} + +ENTRY_FILE_PATTERNS: dict[ProjectType, list[str]] = { + ProjectType.NODE: ["index.js", "index.ts", "app.js", "app.ts", "server.js", "server.ts", "main.js"], + ProjectType.PYTHON: ["main.py", "app.py", "wsgi.py", "manage.py"], + ProjectType.GO: ["main.go", "cmd/server/main.go"], + ProjectType.RUST: ["src/main.rs"], + ProjectType.JAVA: ["src/main/java/**/Application.java", "src/main/java/**/App.java"], + ProjectType.RUBY: ["app.rb", "config.ru", "bin/rails"], + ProjectType.PHP: ["public/index.php", "index.php", "artisan"], +} + + +def _read_file_safe(path: Path) -> str | None: + try: + if path.is_file(): + return path.read_text(encoding="utf-8", errors="ignore") + except OSError: + return None + return None + + +def _find_files_recursive(dir_path: Path, patterns: list[str]) -> list[Path]: + results: list[Path] = [] + for pattern in patterns: + for f in dir_path.rglob(pattern): + parts = f.relative_to(dir_path).parts + if any(part in IGNORE_DIRS for part in parts): + continue + results.append(f) + return results + + +def _has_files(dir_path: Path, filenames: list[str]) -> bool: + return any((dir_path / f).exists() for f in filenames) + + +def _check_content_recursive(dir_path: Path, keywords: list[str]) -> int: + found = 0 + for f in dir_path.rglob("*"): + if not f.is_file(): + continue + parts = f.relative_to(dir_path).parts + if any(part in IGNORE_DIRS for part in parts): + continue + content = _read_file_safe(f) + if content: + found += sum(1 for kw in keywords if kw in content) + return found + + +def _check_dependencies(dir_path: Path, deps: list[str]) -> bool: + for manifest_name in MANIFEST_MAP: + manifest = dir_path / manifest_name + content = _read_file_safe(manifest) + if content: + if all(d in content for d in deps): + return True + return False + + +def _check_patterns(dir_path: Path, patterns: list[str]) -> int: + matched = 0 + for pattern in patterns: + if _find_files_recursive(dir_path, [pattern]): + matched += 1 + return matched + + +def _detect_by_manifest(dir_path: Path) -> ProjectType | None: + for manifest, ptype in MANIFEST_MAP.items(): + if (dir_path / manifest).exists(): + return ptype + return None + + +def _find_entry_files(dir_path: Path) -> list[tuple[str, ProjectType]]: + found: list[tuple[str, ProjectType]] = [] + for ptype, patterns in ENTRY_FILE_PATTERNS.items(): + for pattern in patterns: + if "**" in pattern: + matches = list(dir_path.glob(pattern)) + if matches: + found.append((matches[0].name, ptype)) + else: + f = dir_path / pattern + if f.exists(): + found.append((pattern, ptype)) + return found + + +def _detect_port(dir_path: Path, project_type: ProjectType) -> str | None: + if project_type == ProjectType.NODE: + for pattern in ["*.js", "*.ts"]: + for f in _find_files_recursive(dir_path, [pattern]): + content = _read_file_safe(f) + if content: + m = re.search(r'(?:listen|port)\s*[=:(]\s*(\d{4,5})', content) + if m: + return m.group(1) + elif project_type == ProjectType.PYTHON: + for pattern in ["*.py"]: + for f in _find_files_recursive(dir_path, [pattern]): + content = _read_file_safe(f) + if content: + m = re.search(r'port\s*=\s*(\d{4,5})', content) + if m: + return m.group(1) + return None + + +def evaluate_rule(rule: DetectorRule, dir_path: Path) -> float: + score = 0.0 + max_score = 0.0 + + c = rule.conditions + + if c.files: + max_score += 35.0 + found_files = sum(1 for f in c.files if (dir_path / f).exists()) + if found_files > 0: + score += 35.0 * (found_files / len(c.files)) + + if c.content: + max_score += 30.0 + found_count = _check_content_recursive(dir_path, c.content) + if found_count > 0: + score += 30.0 * min(found_count / len(c.content), 1.0) + + if c.dependencies: + max_score += 20.0 + if _check_dependencies(dir_path, c.dependencies): + score += 20.0 + + if c.patterns: + max_score += 15.0 + matched = _check_patterns(dir_path, c.patterns) + if matched > 0: + score += 15.0 * min(matched / len(c.patterns), 1.0) + + return score / max_score if max_score > 0 else 0.0 + + +def detect_project_type(dir_path: Path | str) -> DetectionResult: + if isinstance(dir_path, str): + dir_path = Path(dir_path).resolve() + if not dir_path.is_dir(): + return DetectionResult(project_type=ProjectType.UNKNOWN, details={"error": "path is not a directory"}) + + manifest_type = _detect_by_manifest(dir_path) + + rules = load_detector_rules() + templates = load_templates() + + all_matches: list[tuple[ProjectType, str, float]] = [] + best_score = 0.0 + result = DetectionResult() + + if manifest_type: + all_matches.append((manifest_type, "manifest", 0.3)) + result.project_type = manifest_type + result.confidence = 0.3 + best_score = 0.3 + + for rule in rules: + score = evaluate_rule(rule, dir_path) + if score > 0: + all_matches.append((rule.type, f"rule:{rule.name}", score)) + if score > best_score: + best_score = score + result.project_type = rule.type + result.confidence = score + result.framework = None + result.matched_template = None + if rule.indicators: + for indicator in rule.indicators: + if _check_indicator(indicator.check_expression, dir_path): + result.framework = indicator.name + break + + for template in templates: + resolved = resolve_template(template) + score = evaluate_template_match(resolved, dir_path) + if score > 0: + all_matches.append((resolved.project_type, f"template:{template.name}", score)) + if score > best_score: + best_score = score + result.project_type = resolved.project_type + result.matched_template = template.name + result.confidence = score + + if result.matched_template is None and result.project_type != ProjectType.UNKNOWN: + best_tpl_score = 0.0 + best_tpl_name: str | None = None + for template in templates: + resolved = resolve_template(template) + if resolved.project_type != result.project_type.value: + continue + score = evaluate_template_match(resolved, dir_path) + if score > best_tpl_score: + best_tpl_score = score + best_tpl_name = template.name + if best_tpl_name is not None: + result.matched_template = best_tpl_name + + entry_files = _find_entry_files(dir_path) + for entry_name, etype in entry_files: + if etype not in [m[0] for m in all_matches]: + all_matches.append((etype, f"entry:{entry_name}", 0.25)) + if 0.25 > best_score: + best_score = 0.25 + result.project_type = etype + + detected_port = _detect_port(dir_path, result.project_type) + + result.confidence = round(best_score, 2) + all_matches.sort(key=lambda m: m[2], reverse=True) + result.all_matches = [(pt, src, round(sc, 2)) for pt, src, sc in all_matches] + + files_found = [] + for f in sorted(dir_path.iterdir())[:20]: + if f.is_file(): + files_found.append(f.name) + result.details["files_found"] = files_found + result.details["entry_files"] = [e[0] for e in entry_files] + if detected_port: + result.details["detected_port"] = detected_port + + return result + + +def detect_mixed_project(dir_path: Path | str) -> list[tuple[ProjectType, str, float]]: + if isinstance(dir_path, str): + dir_path = Path(dir_path).resolve() + result = detect_project_type(dir_path) + return result.all_matches[:5] + + +def _check_indicator(expression: str, dir_path: Path) -> bool: + parts = expression.split("&&") + for part in parts: + part = part.strip() + if " in " in part: + left, right = part.split(" in ", 1) + left = left.strip() + right = right.strip().strip("'\"") + if left.startswith("dependencies."): + dep_name = left[len("dependencies.") :] + pkg_file = dir_path / right + content = _read_file_safe(pkg_file) + if content: + try: + pkg = yaml.safe_load(content) + if isinstance(pkg, dict): + deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})} + if dep_name not in deps: + return False + else: + return False + except Exception: + return False + else: + return False + elif "." in right: + target_file = dir_path / right + content = _read_file_safe(target_file) + if content is None or left.strip("'\"") not in content: + return False + else: + return False + elif "==" in part or "contains" in part: + pass + else: + content = _read_file_safe(dir_path / part.strip()) + if content is None: + return False + return True + + +def evaluate_template_match(template: Template, dir_path: Path) -> float: + score = 0.0 + max_score = 0.0 + + d = template.detectors + + if d.files: + max_score += 50.0 + found = sum(1 for f in d.files if (dir_path / f).exists()) + if found > 0: + score += 50.0 * (found / len(d.files)) + + if d.keywords: + max_score += 50.0 + found = _check_content_recursive(dir_path, d.keywords) + if found > 0: + score += 50.0 * min(found / len(d.keywords), 1.0) + + return score / max_score if max_score > 0 else 0.0 diff --git a/src/acorn/analysis/health.py b/src/acorn/analysis/health.py new file mode 100644 index 0000000..7ff1dda --- /dev/null +++ b/src/acorn/analysis/health.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path + +from acorn.analysis.detector import detect_project_type +from acorn.analysis.health_rules import ALL_RULES, CheckCategory, CheckPriority, CheckRule +from acorn.analysis.insights import ProjectInsights, analyze +from acorn.models import DetectionResult + + +@dataclass +class HealthCheck: + category: CheckCategory + name: str + status: bool + message_key: str + fix_target: str | None + priority: CheckPriority + auto_fixable: bool + detail: str | None = None + + +@dataclass +class HealthReport: + project_path: Path + project_type: str + framework: str | None + confidence: float + checks: list[HealthCheck] = field(default_factory=list) + summary: dict[str, int] = field(default_factory=lambda: {"passed": 0, "failed": 0, "total": 0}) + + def to_dict(self) -> dict: + return { + "project": str(self.project_path), + "type": self.project_type, + "framework": self.framework, + "confidence": self.confidence, + "summary": self.summary, + "checks": [ + { + "category": c.category.value, + "name": c.name, + "status": c.status, + "message_key": c.message_key, + "auto_fixable": c.auto_fixable, + } + for c in self.checks + ], + } + + +def diagnose( + dir_path: Path | str, + detection: DetectionResult | None = None, + insights: ProjectInsights | None = None, +) -> HealthReport: + if isinstance(dir_path, str): + dir_path = Path(dir_path).resolve() + + if detection is None: + detection = detect_project_type(dir_path) + if insights is None: + insights = analyze(dir_path) + + checks: list[HealthCheck] = [] + + for rule in ALL_RULES: + full_path = dir_path / rule.rel_path + exists = full_path.exists() + status = exists + + if exists: + message_key = f"check_{rule.rel_path}_present" + else: + message_key = f"check_{rule.rel_path}_absent" + + checks.append( + HealthCheck( + category=rule.category, + name=rule.rel_path, + status=status, + message_key=message_key, + fix_target=rule.fix_target, + priority=rule.priority, + auto_fixable=rule.auto_fixable, + detail=None, + ) + ) + + passed = sum(1 for c in checks if c.status) + failed = len(checks) - passed + + return HealthReport( + project_path=dir_path, + project_type=detection.project_type.value if detection else "unknown", + framework=detection.framework if detection else None, + confidence=detection.confidence if detection else 0.0, + checks=checks, + summary={"passed": passed, "failed": failed, "total": len(checks)}, + ) diff --git a/src/acorn/analysis/health_rules.py b/src/acorn/analysis/health_rules.py new file mode 100644 index 0000000..2802b1d --- /dev/null +++ b/src/acorn/analysis/health_rules.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class CheckCategory(str, Enum): + AI_READINESS = "ai_readiness" + DEVOPS = "devops" + CODE_QUALITY = "code_quality" + + +class CheckPriority(str, Enum): + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + + +@dataclass +class CheckRule: + category: CheckCategory + rel_path: str + fix_target: str | None + priority: CheckPriority + auto_fixable: bool + friendly_name: str = "" + + +AI_CHECKS: list[CheckRule] = [ + CheckRule(CheckCategory.AI_READINESS, ".cursorrules", "cursorrules", CheckPriority.HIGH, True, ".cursorrules"), + CheckRule(CheckCategory.AI_READINESS, "CLAUDE.md", "claude-md", CheckPriority.HIGH, True, "CLAUDE.md"), + CheckRule(CheckCategory.AI_READINESS, ".github/copilot-instructions.md", "copilot", CheckPriority.MEDIUM, True, ".github/copilot-instructions.md"), +] + +DEVOPS_CHECKS: list[CheckRule] = [ + CheckRule(CheckCategory.DEVOPS, "Dockerfile", "dockerfile", CheckPriority.MEDIUM, True, "Dockerfile"), + CheckRule(CheckCategory.DEVOPS, ".dockerignore", "dockerignore", CheckPriority.MEDIUM, True, ".dockerignore"), + CheckRule(CheckCategory.DEVOPS, ".github/workflows/ci.yml", None, CheckPriority.LOW, False, ".github/workflows/ci.yml"), +] + +QUALITY_CHECKS: list[CheckRule] = [ + CheckRule(CheckCategory.CODE_QUALITY, ".gitignore", "gitignore", CheckPriority.HIGH, True, ".gitignore"), +] + +ALL_RULES: list[CheckRule] = AI_CHECKS + DEVOPS_CHECKS + QUALITY_CHECKS diff --git a/src/acorn/analysis/insights.py b/src/acorn/analysis/insights.py new file mode 100644 index 0000000..dc0d476 --- /dev/null +++ b/src/acorn/analysis/insights.py @@ -0,0 +1,352 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path + +from acorn._compat import tomllib + +SOURCE_EXTENSIONS = {".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".java", ".rb", ".php", ".c", ".cpp", ".h", ".hpp"} +IGNORE_DIRS = { + ".git", "node_modules", "__pycache__", ".venv", "venv", + "target", "build", "dist", ".next", ".nuxt", + ".idea", ".vscode", ".DS_Store", "vendor", +} + + +@dataclass +class ProjectInsights: + language: str = "unknown" + framework: str | None = None + package_manager: str | None = None + + has_src_dir: bool = False + has_app_router: bool = False + src_structure: dict[str, list[str]] = field(default_factory=dict) + api_route_paths: list[str] = field(default_factory=list) + api_route_detection_method: str = "none" + + key_dependencies: dict[str, str] = field(default_factory=dict) + + bundler: str | None = None + css_framework: str | None = None + orm: str | None = None + test_runner: str | None = None + auth_lib: str | None = None + + entry_points: list[str] = field(default_factory=list) + + +_JS_FRAMEWORKS: dict[str, tuple[str | None, str | None, str | None]] = { + "next": ("Next.js", None, None), + "nuxt": ("Nuxt", None, None), + "@remix-run/react": ("Remix", None, None), + "gatsby": ("Gatsby", None, None), + "express": ("Express", None, None), + "fastify": ("Fastify", None, None), + "@nestjs/core": ("NestJS", None, None), + "@sveltejs/kit": ("SvelteKit", None, None), + "vue": ("Vue", None, None), + "react": ("React", None, None), + "hono": ("Hono", None, None), + "elysia": ("Elysia", None, None), +} + +_JS_BUNDLERS = {"vite", "webpack", "rollup", "esbuild", "turbo", "parcel", "tsup", "unbuild"} +_JS_CSS = {"tailwindcss", "unocss", "styled-components", "@emotion/react", "sass", "less", "postcss"} +_JS_ORM = {"prisma", "drizzle-orm", "typeorm", "sequelize", "mongoose", "knex"} +_JS_TEST = {"vitest", "jest", "playwright", "cypress", "ava", "mocha", "jasmine"} +_JS_AUTH = {"next-auth", "@auth/core", "passport", "lucia-auth", "clerk-sdk-node"} + + +def _read_json_safe(path: Path) -> dict | None: + try: + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8", errors="ignore")) + except (OSError, json.JSONDecodeError): + return None + return None + + +def _read_toml_safe(path: Path) -> dict | None: + try: + if path.is_file() and tomllib is not None: + return tomllib.loads(path.read_text(encoding="utf-8", errors="ignore")) + except (OSError, Exception): + return None + return None + + +def _read_text_safe(path: Path) -> str | None: + try: + if path.is_file(): + return path.read_text(encoding="utf-8", errors="ignore") + except OSError: + return None + return None + + +def _get_deps(pkg: dict) -> dict[str, str]: + return {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})} + + +def _analyze_js_deps(ins: ProjectInsights, pkg: dict) -> None: + deps = _get_deps(pkg) + ins.key_dependencies = dict(sorted(deps.items())) + ins.package_manager = pkg.get("packageManager", "").split("@")[0] or None + if not ins.package_manager: + if (Path(pkg.get("__path", "")) if "__path" in pkg else Path()).parent / "yarn.lock": + ins.package_manager = "yarn" + elif (Path(pkg.get("__path", "")) if "__path" in pkg else Path()).parent / "pnpm-lock.yaml": + ins.package_manager = "pnpm" + + for dep_name, dep_version in deps.items(): + if dep_name in _JS_FRAMEWORKS: + fw, _, _ = _JS_FRAMEWORKS[dep_name] + if ins.framework is None: + ins.framework = fw + if dep_name in _JS_BUNDLERS and ins.bundler is None: + ins.bundler = dep_name + if dep_name in _JS_CSS and ins.css_framework is None: + ins.css_framework = dep_name + if dep_name in _JS_ORM and ins.orm is None: + ins.orm = dep_name + if dep_name in _JS_TEST and ins.test_runner is None: + ins.test_runner = dep_name + if dep_name in _JS_AUTH and ins.auth_lib is None: + ins.auth_lib = dep_name + + +def _analyze_js_structure(ins: ProjectInsights, dir_path: Path, pkg: dict) -> None: + if ins.framework == "Next.js": + app_dir = dir_path / "app" + if app_dir.is_dir(): + ins.has_app_router = True + ins.api_route_detection_method = "nextjs-app-router" + for route_file in app_dir.rglob("route.ts"): + rel = route_file.relative_to(app_dir) + route_path = "/" + str(rel.parent) + ins.api_route_paths.append(route_path) + for route_file in app_dir.rglob("route.js"): + rel = route_file.relative_to(app_dir) + route_path = "/" + str(rel.parent) + ins.api_route_paths.append(route_path) + + src_dir = dir_path / "src" + if src_dir.is_dir(): + ins.has_src_dir = True + pages = sorted(str(f.relative_to(src_dir)) for f in src_dir.rglob("*") if f.is_file()) + by_top = {} + for p in pages: + top = p.split("/")[0] + by_top.setdefault(top, []).append(p) + ins.src_structure = by_top + + +def _analyze_py_deps(ins: ProjectInsights, pyproj: dict) -> None: + project = pyproj.get("project", {}) + deps = project.get("dependencies", []) + optional = project.get("optional-dependencies", {}) + + all_deps = list(deps) + for group in optional.values(): + all_deps.extend(group) + + fw_map = { + "fastapi": "FastAPI", + "flask": "Flask", + "django": "Django", + "starlette": "Starlette", + "litestar": "Litestar", + "aiohttp": "aiohttp", + "tornado": "Tornado", + "bottle": "Bottle", + "sanic": "Sanic", + "quart": "Quart", + } + for dep_line in all_deps: + dep_name = dep_line.split("[")[0].split(">")[0].split("<")[0].split("=")[0].split("~")[0].strip() + if dep_name in fw_map and ins.framework is None: + ins.framework = fw_map[dep_name] + + tool = pyproj.get("tool", {}) + if ins.orm is None: + if tool.get("sqlalchemy"): + ins.orm = "sqlalchemy" + if ins.test_runner is None: + if tool.get("pytest"): + ins.test_runner = "pytest" + elif tool.get("unittest"): + ins.test_runner = "unittest" + + +def _analyze_py_requirements(ins: ProjectInsights, dir_path: Path) -> None: + req = _read_text_safe(dir_path / "requirements.txt") + if req is None: + return + fw_map = {"fastapi": "FastAPI", "flask": "Flask", "django": "Django"} + for line in req.splitlines(): + line = line.strip().lower() + for key, val in fw_map.items(): + if line.startswith(key) and ins.framework is None: + ins.framework = val + + +def _analyze_src_structure(ins: ProjectInsights, dir_path: Path) -> None: + src_dir = dir_path / "src" + if not src_dir.is_dir(): + return + ins.has_src_dir = True + pages = sorted(str(f.relative_to(src_dir)) for f in src_dir.rglob("*") if f.is_file()) + by_top = {} + for p in pages: + top = p.split("/")[0] + by_top.setdefault(top, []).append(p) + ins.src_structure = by_top + + app_dir = src_dir / "app" + if app_dir.is_dir(): + for route_file in app_dir.rglob("route.ts"): + rel = route_file.relative_to(app_dir) + ins.api_route_paths.append("/" + str(rel.parent)) + ins.api_route_detection_method = "nextjs-app-router" + for route_file in app_dir.rglob("route.js"): + rel = route_file.relative_to(app_dir) + ins.api_route_paths.append("/" + str(rel.parent)) + ins.api_route_detection_method = "nextjs-app-router" + + +def _find_entry_points(ins: ProjectInsights, dir_path: Path) -> None: + entry_patterns = { + "node": ["index.js", "index.ts", "app.js", "app.ts", "server.js", "server.ts", "main.js"], + "python": ["main.py", "app.py", "wsgi.py", "manage.py"], + "go": ["main.go"], + "rust": ["src/main.rs"], + } + for fname in entry_patterns.get(ins.language, []): + f = dir_path / fname + if f.is_file(): + ins.entry_points.append(fname) + if ins.language == "java": + for f in dir_path.rglob("Application.java"): + ins.entry_points.append(str(f.relative_to(dir_path))) + if ins.language == "ruby": + for fname in ["app.rb", "config.ru", "bin/rails"]: + f = dir_path / fname + if f.is_file(): + ins.entry_points.append(fname) + if ins.language == "php": + for fname in ["public/index.php", "index.php", "artisan"]: + f = dir_path / fname + if f.is_file(): + ins.entry_points.append(fname) + + +def analyze(dir_path: Path | str) -> ProjectInsights: + if isinstance(dir_path, str): + dir_path = Path(dir_path).resolve() + if not dir_path.is_dir(): + return ProjectInsights() + + ins = ProjectInsights() + + pkg = _read_json_safe(dir_path / "package.json") + if pkg: + ins.language = "node" + _analyze_js_deps(ins, pkg) + _analyze_js_structure(ins, dir_path, pkg) + + pyproj = _read_toml_safe(dir_path / "pyproject.toml") + if pyproj and ins.language == "unknown": + ins.language = "python" + _analyze_py_deps(ins, pyproj) + elif (dir_path / "requirements.txt").exists() and ins.language == "unknown": + ins.language = "python" + _analyze_py_requirements(ins, dir_path) + elif (dir_path / "setup.py").exists() and ins.language == "unknown": + ins.language = "python" + elif (dir_path / "setup.cfg").exists() and ins.language == "unknown": + ins.language = "python" + elif (dir_path / "Pipfile").exists() and ins.language == "unknown": + ins.language = "python" + + if (dir_path / "go.mod").exists() and ins.language == "unknown": + ins.language = "go" + go_mod = _read_text_safe(dir_path / "go.mod") + if go_mod: + m = re.search(r"^module\s+(\S+)", go_mod, re.MULTILINE) + if m: + ins.key_dependencies["module"] = m.group(1) + + if (dir_path / "Cargo.toml").exists() and ins.language == "unknown": + ins.language = "rust" + cargo = _read_toml_safe(dir_path / "Cargo.toml") + if cargo: + deps = cargo.get("dependencies", {}) + if isinstance(deps, dict): + ins.key_dependencies = {k: str(v) if not isinstance(v, dict) else (v.get("version", "*")) for k, v in deps.items()} + fw_map = {"actix-web": "Actix Web", "axum": "Axum", "rocket": "Rocket", "tide": "Tide", "warp": "Warp"} + for dep_name in deps if isinstance(deps, dict) else []: + if dep_name in fw_map and ins.framework is None: + ins.framework = fw_map[dep_name] + + if (dir_path / "pom.xml").exists() and ins.language == "unknown": + ins.language = "java" + elif (dir_path / "build.gradle").exists() and ins.language == "unknown": + ins.language = "java" + elif (dir_path / "build.gradle.kts").exists() and ins.language == "unknown": + ins.language = "java" + + if (dir_path / "Gemfile").exists() and ins.language == "unknown": + ins.language = "ruby" + gemfile = _read_text_safe(dir_path / "Gemfile") + if gemfile: + fw_map = {"rails": "Rails", "sinatra": "Sinatra", "hanami": "Hanami", "roda": "Roda"} + for line in gemfile.splitlines(): + line = line.strip() + for key, val in fw_map.items(): + if re.match(rf'^\s*gem\s+["\']{key}["\']', line) and ins.framework is None: + ins.framework = val + + if (dir_path / "composer.json").exists() and ins.language == "unknown": + ins.language = "php" + composer = _read_json_safe(dir_path / "composer.json") + if composer: + deps = _get_deps(composer) + ins.key_dependencies = dict(sorted(deps.items())) + fw_map = {"laravel/framework": "Laravel", "symfony/symfony": "Symfony", "cakephp/cakephp": "CakePHP", "codeigniter/framework": "CodeIgniter"} + for dep_name in deps: + if dep_name in fw_map and ins.framework is None: + ins.framework = fw_map[dep_name] + + if (dir_path / "deno.json").exists() and ins.language == "unknown": + ins.language = "deno" + elif (dir_path / "deno.jsonc").exists() and ins.language == "unknown": + ins.language = "deno" + + if (dir_path / "bun.lockb").exists() and ins.language == "unknown": + ins.language = "bun" + elif (dir_path / "bun.lock").exists() and ins.language == "unknown": + ins.language = "bun" + + _analyze_src_structure(ins, dir_path) + _find_entry_points(ins, dir_path) + + return ins + + +def has_source_code(dir_path: Path | str) -> bool: + if isinstance(dir_path, str): + dir_path = Path(dir_path).resolve() + if not dir_path.is_dir(): + return False + try: + for f in dir_path.rglob("*"): + if any(part in IGNORE_DIRS for part in f.relative_to(dir_path).parts): + continue + if f.is_file() and f.suffix in SOURCE_EXTENSIONS: + return True + except (PermissionError, OSError): + return False + return False diff --git a/src/acorn/cli.py b/src/acorn/cli.py index fafd114..a3604e0 100644 --- a/src/acorn/cli.py +++ b/src/acorn/cli.py @@ -4,72 +4,31 @@ import shutil import sys from pathlib import Path -from shutil import copytree, ignore_patterns from acorn import __version__ -from acorn.analyzer import AnalyzeOptions -from acorn.analyzer import analyze as ai_analyze +from acorn.commands.admin import cmd_check_update, cmd_completion, cmd_export, cmd_import +from acorn.commands.analyze_cmd import cmd_analyze +from acorn.commands.clean import cmd_clean +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.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 from acorn.check_update import check_pypi_version -from acorn.composer import compose_and_generate -from acorn.config import ( - TEMPLATES_DIR, - ensure_dirs, - export_config, - find_template_by_project_type, - import_config, - init_project_config, - load_config, - load_project_config, - load_templates, - remove_from_manifest, -) -from acorn.detector import detect_mixed_project, detect_project_type -from acorn.i18n import cmd_text, detect_language, set_language -from acorn.i18n import error as i18n_error -from acorn.i18n import prompt as i18n_prompt -from acorn.i18n import text as i18n_text -from acorn.log import debug as log_debug -from acorn.log import error as log_error -from acorn.log import info as log_info -from acorn.log import set_level as log_set_level -from acorn.log import warning as log_warning +from acorn.config import CONFIG_FILE, TEMPLATES_DIR, load_config, load_templates +from acorn.detector import detect_project_type +from acorn.format import EXIT_ERROR, EXIT_SUCCESS, color, confirm_or_exit, suggest_help from acorn.marketplace import install_from_github, search_all, search_github -from acorn.models import GenerationOptions, ProjectType +from acorn.template_engine import list_templates +from acorn.i18n import detect_language, set_language +from acorn.log import debug as log_debug, error as log_error, info as log_info, set_level as log_set_level +from acorn.log import warning as log_warning from acorn.security import format_findings, scan_template -from acorn.telemetry import is_enabled as telemetry_is_enabled -from acorn.telemetry import set_enabled as telemetry_set_enabled -from acorn.template_engine import ( - DOCKER_FILES, - auto_generate, - generate_from_template, - list_templates, - save_as_template_from_project, -) +from acorn.telemetry import is_enabled as telemetry_is_enabled, set_enabled as telemetry_set_enabled from acorn.wizard import cmd_wizard -EXIT_SUCCESS = 0 -EXIT_ERROR = 1 -EXIT_NO_MATCH = 2 - - -def _suggest_help() -> str: - return color(" (use --help for usage)", "dim") - - -def color(text: str, code: str) -> str: - colors = { - "green": "\033[32m", - "yellow": "\033[33m", - "red": "\033[31m", - "blue": "\033[34m", - "cyan": "\033[36m", - "bold": "\033[1m", - "dim": "\033[2m", - "reset": "\033[0m", - } - c = colors.get(code, "") - reset = colors["reset"] - return f"{c}{text}{reset}" +_confirm_or_exit = confirm_or_exit def build_parser() -> argparse.ArgumentParser: @@ -134,6 +93,17 @@ def build_parser() -> argparse.ArgumentParser: g_admin.add_argument("--scan", metavar="PATH", help="扫描模板或项目的安全问题") g_admin.add_argument("--config", metavar="FILE", help="指定全局配置文件路径") + 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") + g_fix.add_argument("--fix-dockerignore", action="store_true", dest="fix_dockerignore", help="生成 .dockerignore") + g_fix.add_argument("--fix-gitignore", action="store_true", dest="fix_gitignore", help="生成 .gitignore") + g_fix.add_argument("--fix-cursorrules", action="store_true", dest="fix_cursorrules", help="生成 .cursorrules") + g_fix.add_argument("--fix-claude-md", action="store_true", dest="fix_claude_md", help="生成 CLAUDE.md") + g_fix.add_argument("--fix-copilot", action="store_true", dest="fix_copilot", help="生成 GitHub Copilot instructions") + g_fix.add_argument("--fix-ai", action="store_true", dest="fix_ai", help="生成所有 AI 配置文件") + g_fix.add_argument("--fix-all", action="store_true", dest="fix_all", help="修复所有可自动修复项") + g_global = parser.add_argument_group("global options") g_global.add_argument("--lang", metavar="LANG", help="语言 (en/zh)") g_global.add_argument("--verbose", action="store_true", help="详细输出") @@ -165,773 +135,14 @@ def _setup_logging(args: argparse.Namespace) -> None: log_set_level(config.get("log_level", "INFO")) -def cmd_list(json_mode: bool = False) -> int: - templates = list_templates() - if not templates: - log_info("No templates found.") - return EXIT_SUCCESS - - if json_mode: - from acorn.json_output import print_json - print_json({"templates": templates, "count": len(templates)}) - return EXIT_SUCCESS - - title = cmd_text("list_title", count=str(len(templates))) - print(f"\n{color(title, 'bold')}") - print("-" * 60) - for t in templates: - name = color(t["name"], "cyan") - print(f" {name:<20} {t['description']:<30} v{t['version']}") - if t["files"]: - print(f" {'':>20} files: {', '.join(t['files'])}") - print() - return EXIT_SUCCESS - - -def cmd_add(path: str) -> int: - src = Path(path).resolve() - if not src.is_dir(): - log_error(f"'{path}' is not a valid directory") - return EXIT_ERROR - - template_yaml = src / "template.yaml" - if not template_yaml.exists(): - log_error(f"No template.yaml found at {path}") - return EXIT_ERROR - - dest = TEMPLATES_DIR / src.name - if dest.exists(): - log_error(f"Template '{src.name}' already exists") - return EXIT_ERROR - - ensure_dirs() - copytree(src, dest, ignore=ignore_patterns("__pycache__", ".git")) - log_info(f"Template '{src.name}' added to {dest}") - print(f"{color('✓', 'green')} Template '{src.name}' added") - return EXIT_SUCCESS - - -def cmd_remove(name: str) -> int: - dest = TEMPLATES_DIR / name - if not dest.exists(): - log_error(f"Template '{name}' not found") - return EXIT_ERROR - - shutil.rmtree(dest) - log_info(f"Template '{name}' removed") - print(f"{color('✓', 'green')} Template '{name}' removed") - return EXIT_SUCCESS - - -def cmd_init(args: argparse.Namespace) -> int: - target_dir = Path(args.dir).resolve() - if not target_dir.is_dir(): - log_error(f"Directory '{args.dir}' does not exist") - return EXIT_ERROR - - config = init_project_config(target_dir, template_name=args.template, dry_run=args.dry_run) - return EXIT_SUCCESS if config else EXIT_ERROR - - -def cmd_search(query: str, offline: bool = False) -> int: - if offline: - log_warning("Offline mode - skipping search") - return EXIT_ERROR - - print(f"{color('Searching for:', 'bold')} {query}") - results = search_all(query) - if not results: - results = search_github(query) - - if not results: - log_info(f"No results found for '{query}'") - return EXIT_NO_MATCH - - print(f"\n{color(cmd_text('search_results', query=query), 'bold')}") - print("-" * 60) - for r in results: - stars = color(f"★{r['stars']}", "yellow") if r["stars"] > 0 else "" - print(f" {color(r['full_name'], 'cyan')} {stars}") - if r["description"]: - print(f" {r['description'][:70]}") - print() - return EXIT_SUCCESS - - -def cmd_install(repo: str, dry_run: bool = False, offline: bool = False) -> int: - if offline: - log_warning("Offline mode - skipping install") - return EXIT_ERROR - - if "/" not in repo: - log_error(f"Invalid repo format '{repo}'. Use user/repo format.") - return EXIT_ERROR - - log_info(f"Installing template from {repo}") - result = install_from_github(repo, dry_run=dry_run) - if result: - print(f"{color('✓', 'green')} Template installed from {repo}") - return EXIT_SUCCESS - return EXIT_ERROR - - -def _confirm_or_exit(prompt_text: str, default_yes: bool = True) -> bool: - default = "Y/n" if default_yes else "y/N" - try: - choice = input(f"{color('?', 'blue')} {prompt_text} [{default}]: ").strip().lower() - if default_yes: - return choice not in ("n", "no") - return choice in ("y", "yes") - except (EOFError, KeyboardInterrupt): - print() - return False - - -def _handle_mixed_project(target_dir: Path, options: GenerationOptions) -> str | None: - matches = detect_mixed_project(target_dir) - if len(matches) <= 1: - return None - - print(f"\n{color('Detected multiple project types:', 'yellow')}") - for i, (ptype, src, score) in enumerate(matches[:5], 1): - print(f" [{i}] {color(ptype.value, 'cyan')} ({src}, confidence: {score:.0%})") - print(" [0] Cancel") - - if not options.interactive: - best = matches[0] - log_debug(f"Auto-selecting best match: {best[0].value}") - return best[0].value - - try: - choice = input(f"\n{color('?', 'blue')} Select (1-{min(len(matches), 5)}): ").strip() - if choice.isdigit(): - idx = int(choice) - 1 - if 0 <= idx < len(matches): - return matches[idx][0].value - except (EOFError, KeyboardInterrupt): - pass - return None - - -def cmd_generate(args: argparse.Namespace) -> int: - target_dir = Path(args.dir).resolve() - if not target_dir.is_dir(): - log_error(i18n_error("dir_not_exist", dir=args.dir) + _suggest_help()) - return EXIT_ERROR - - options = GenerationOptions( - force=args.force, - dry_run=args.dry_run, - interactive=args.interactive, - regenerate=args.regenerate, - template_name=args.template, - save=args.save or bool(args.save_as), - verbose=args.verbose, - debug=args.debug, - quiet=args.quiet, - offline=args.offline, - lang=args.lang, - ) - - if args.var: - for v in args.var: - if "=" in v: - key, val = v.split("=", 1) - options.variables[key.strip()] = val.strip() - - if args.template: - generated = generate_from_template(args.template, target_dir, options) - if options.save and not options.dry_run: - save_name = args.save_as or target_dir.name - save_as_template_from_project(target_dir, name=save_name, dry_run=args.dry_run) - if options.dry_run: - return EXIT_SUCCESS - return EXIT_SUCCESS if generated else EXIT_ERROR - - project_config = load_project_config(target_dir) - if "template" in project_config and not args.template: - options.template_name = project_config["template"] - log_info(f"Using project-config template: {options.template_name}") - generated = generate_from_template(options.template_name, target_dir, options) - if options.dry_run: - return EXIT_SUCCESS - return EXIT_SUCCESS if generated else EXIT_ERROR - - is_empty = len(list(target_dir.iterdir())) <= 1 - - if is_empty: - log_info(cmd_text("empty_project")) - print(f"\n{color(cmd_text('empty_project'), 'yellow')}") - if args.interactive: - templates = load_templates() - print(f"\n{color(cmd_text('list_title', count=str(len(templates))), 'bold')}") - for i, t in enumerate(templates, 1): - print(f" [{i}] {t.name}: {t.description}") - print(" [a] Auto-generate") - - try: - choice = input(f"\n{color('?', 'blue')} Select: ").strip().lower() - except (EOFError, KeyboardInterrupt): - choice = "a" - - if choice.isdigit(): - idx = int(choice) - 1 - if 0 <= idx < len(templates): - generate_from_template(templates[idx].name, target_dir, options) - return EXIT_SUCCESS - auto_generate("unknown", target_dir, options) - return EXIT_SUCCESS - else: - log_info("Run with --interactive or --template") - return EXIT_NO_MATCH - - log_info(i18n_text("detecting", path=str(target_dir))) - print(f"{color(cmd_text('scanning'), 'bold')} in {target_dir}...") - result = detect_project_type(target_dir) - - mixed_selection = _handle_mixed_project(target_dir, options) - - if result.project_type != ProjectType.UNKNOWN: - confidence_display = f"{result.confidence:.0%}" - detected_msg = i18n_text("detected", type=result.project_type.value, confidence=confidence_display) - print(f"\n{color('√', 'green')} {detected_msg}") - if result.framework: - print(f" {i18n_text('framework', name=result.framework)}") - if result.matched_template: # pragma: no branch (fallback always sets for detected types) - print(f" {i18n_text('template', name=result.matched_template)} #{color(result.project_type.value, 'cyan')}") - if "detected_port" in result.details: - port_msg = i18n_text("port", port=result.details["detected_port"]) - print(f" {port_msg}") - log_debug(f"Detection details: {result.details}") - else: - print(f"\n{color(i18n_text('not_detected'), 'yellow')}") - - if result.matched_template: - if args.interactive: - confirm_prompt = i18n_prompt("confirm_template", name=result.matched_template) - if not _confirm_or_exit(confirm_prompt): - templates = load_templates() - print(f"\n{color(i18n_prompt('select_template'), 'bold')}") - for i, t in enumerate(templates, 1): - print(f" [{i}] {t.name}: {t.description}") - try: - choice = input(f"\n{color('?', 'blue')} Select: ").strip().lower() - except (EOFError, KeyboardInterrupt): - choice = "" - if choice.isdigit(): - idx = int(choice) - 1 - if 0 <= idx < len(templates): - generate_from_template(templates[idx].name, target_dir, options) - return EXIT_SUCCESS - - print() - generated = generate_from_template(result.matched_template, target_dir, options) - if options.save and not options.dry_run: - save_name = args.save_as or target_dir.name - save_as_template_from_project(target_dir, name=save_name, dry_run=args.dry_run) - if options.dry_run: - return EXIT_SUCCESS - return EXIT_SUCCESS if generated else EXIT_NO_MATCH - else: - project_type_str = mixed_selection or ( - result.project_type.value if result.project_type != ProjectType.UNKNOWN else "unknown" - ) - - if args.interactive: - templates = load_templates() - title = i18n_prompt("select_template") - print(f"\n{color(title, 'bold')}") - for i, t in enumerate(templates, 1): - print(f" [{i}] {t.name}: {t.description}") - print(f" [a] Auto-generate for {project_type_str}") - print(" [s] Skip") - - try: - choice = input(f"\n{color('?', 'blue')} {i18n_prompt('select_option')}: ").strip().lower() - except (EOFError, KeyboardInterrupt): - choice = "s" - - if choice.isdigit(): - idx = int(choice) - 1 - if 0 <= idx < len(templates): - generate_from_template(templates[idx].name, target_dir, options) - return EXIT_SUCCESS - log_info("Skipped.") - return EXIT_NO_MATCH - elif choice == "a": - auto_generate(project_type_str, target_dir, options) - return EXIT_SUCCESS - else: - log_info("Skipped.") - return EXIT_NO_MATCH - else: - log_info(i18n_text("no_match")) - return EXIT_NO_MATCH - - -def cmd_dockerize(args: argparse.Namespace) -> int: - target_dir = Path(args.dir).resolve() - if not target_dir.is_dir(): - log_error(f"Directory '{args.dir}' does not exist{_suggest_help()}") - return EXIT_ERROR - - result = detect_project_type(target_dir) - if result.project_type == ProjectType.UNKNOWN: - log_error("Cannot detect project type in target directory") - return EXIT_ERROR - - template_name = result.matched_template - if not template_name: - tpl = find_template_by_project_type(result.project_type) - if tpl: - template_name = tpl.name - - if not template_name: - log_error(f"No template found for project type '{result.project_type.value}'") - return EXIT_ERROR - - options = GenerationOptions( - force=args.force, - dry_run=args.dry_run, - regenerate=args.regenerate, - verbose=args.verbose, - debug=args.debug, - quiet=args.quiet, - offline=args.offline, - lang=args.lang, - ) - - log_info(f"Generating Docker configuration for {result.project_type.value} project...") - generated = generate_from_template(template_name, target_dir, options, only=DOCKER_FILES) - if not generated: - log_info("No Docker files generated (they may already exist)") - return EXIT_ERROR - return EXIT_SUCCESS - - -CI_WORKFLOWS_DIR = ".github/workflows" -CI_FILES = {"ci.yml", "deploy.yml"} - - -def _ci_setup_action(project_type: str) -> str: - setups = { - "node": "actions/setup-node@v4\n with:\n node-version: '20'", - "python": "actions/setup-python@v5\n with:\n python-version: '3.12'", - "go": "actions/setup-go@v5\n with:\n go-version: '1.22'", - "rust": "actions/setup-rust@v1\n with:\n toolchain: stable", - "java": "actions/setup-java@v4\n with:\n java-version: '21'\n distribution: temurin", - "php": "actions/setup-php@v5\n with:\n php-version: '8.3'", - } - return setups.get(project_type, "actions/setup-node@v4\n with:\n node-version: '20'") - - -def _ci_run_command(project_type: str) -> str: - commands = { - "node": "npm ci\n - run: npm test", - "python": "pip install -r requirements.txt\n - run: pytest", - "go": "go mod download\n - run: go test ./...", - "rust": "cargo build\n - run: cargo test", - "java": "./mvnw verify", - "php": "composer install\n - run: phpunit", - } - return commands.get(project_type, "echo 'Run tests'") - - -def _generate_ci_yml(project_type: str) -> str: - setup = _ci_setup_action(project_type) - run = _ci_run_command(project_type) - return f"""name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Setup - uses: {setup} - - name: Install dependencies - run: {run.split(chr(10))[0]} - - name: Test - run: {run.split(chr(10))[-1].replace('- run: ', '') if chr(10) in run else run} -""" - - -def _generate_deploy_yml(project_type: str) -> str: - return f"""name: Deploy - -on: - push: - branches: [main] - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Setup - uses: {_ci_setup_action(project_type)} - - name: Build - run: {"npm run build" if project_type == "node" else "echo 'Build step not configured'"} - - name: Deploy - run: echo "Add your deploy steps here" -""" - - -def cmd_add_ci(args: argparse.Namespace) -> int: - target_dir = Path(args.dir).resolve() - if not target_dir.is_dir(): - log_error(f"Directory '{args.dir}' does not exist") - return EXIT_ERROR - - result = detect_project_type(target_dir) - if result.project_type == ProjectType.UNKNOWN: - log_error("Cannot detect project type") - return EXIT_ERROR - - project_type = result.project_type.value - workflows_dir = target_dir / CI_WORKFLOWS_DIR - workflows_dir.mkdir(parents=True, exist_ok=True) - - files_to_generate = ["ci.yml"] - generated = [] - - for fname in files_to_generate: - dest = workflows_dir / fname - if dest.exists() and not args.force: - log_info(f"Skipping {CI_WORKFLOWS_DIR}/{fname} (already exists)") - continue - - content = _generate_ci_yml(project_type) if fname == "ci.yml" else _generate_deploy_yml(project_type) - - if args.dry_run: - print(f" 🔍 Would generate: {CI_WORKFLOWS_DIR}/{fname}") - continue - - dest.write_text(content) - print(f" ✓ Generated: {CI_WORKFLOWS_DIR}/{fname}") - generated.append(dest) - - if not generated: - log_info("No CI files generated") - return EXIT_ERROR - log_info(f"Generated {len(generated)} CI workflow file(s)") - return EXIT_SUCCESS - - -def cmd_analyze(args: argparse.Namespace) -> int: - target_dir = Path(args.dir).resolve() - if not target_dir.is_dir(): - log_error(f"Directory '{args.dir}' does not exist") - return EXIT_ERROR - - options = AnalyzeOptions( - allow_ai=args.allow_ai, - dry_run=args.dry_run, - ) - result = ai_analyze(target_dir, options) - - if args.json: - from acorn.json_output import print_json - data = { - "source": result.source, - "project_type": result.detection.project_type.value if result.detection else None, - "confidence": result.detection.confidence if result.detection else 0, - "framework": result.detection.framework if result.detection and result.detection.framework else None, - "matched_template": result.detection.matched_template if result.detection and result.detection.matched_template else None, - "ai_suggestion": result.ai_suggestion, - } - print_json(data) - return EXIT_SUCCESS - - print(f"\n{color('Analysis Report', 'bold')}") - print("=" * 50) - if result.detection: - d = result.detection - project_label = i18n_text("detected", type=d.project_type.value, confidence=f"{d.confidence:.0%}") - print(f" {project_label}") - if d.framework: - print(f" {i18n_text('framework', name=d.framework)}") - if d.matched_template: - print(f" {i18n_text('template', name=d.matched_template)}") - if d.all_matches: - print(f"\n {color('All matches:', 'dim')}") - for ptype, src, score in d.all_matches: - print(f" - {ptype.value} ({src}, {score:.0%})") - - print(f"\n Source: {color(result.source, 'cyan')}") - if result.ai_suggestion: - print(f"\n {color('AI Suggestion:', 'bold')}") - print(f" {result.ai_suggestion}") - return EXIT_SUCCESS - - -def cmd_clean(args: argparse.Namespace) -> int: - import shutil as shutil_mod - - from acorn.config import load_manifest, load_project_lock - target_dir = Path(args.dir).resolve() - if not target_dir.is_dir(): - log_error(f"Directory '{args.dir}' does not exist") - return EXIT_ERROR - - clean_all = args.all - keep_templates = args.keep_templates - - if clean_all: - acorn_dir = target_dir / ".acorn" - if acorn_dir.exists(): - if args.dry_run: - print(f" 🔍 Would remove: {acorn_dir}/") - else: - shutil_mod.rmtree(acorn_dir) - print(f" ✓ Removed: {acorn_dir}/") - if not keep_templates: - manifest = load_manifest() - key = str(target_dir.resolve()) - if key in manifest: - if not args.dry_run: - remove_from_manifest(target_dir) - print(" ✓ Removed from manifest") - log_info("Clean all complete") - return EXIT_SUCCESS - - lock = load_project_lock(target_dir) - if not lock or "files" not in lock: - log_info("No lock file found — nothing to clean") - return EXIT_ERROR - - files = lock.get("files", []) - if not files: - log_info("No generated files recorded in lock") - return EXIT_ERROR - - removed = 0 - for rel_path in files: - f = target_dir / rel_path - if f.exists(): - if args.dry_run: - print(f" 🔍 Would remove: {rel_path}") - else: - f.unlink() - print(f" ✓ Removed: {rel_path}") - removed += 1 - - if removed == 0 and not args.dry_run: - log_info("No files to clean") - else: - log_info(f"Cleaned {removed} generated file(s)") - return EXIT_SUCCESS - - -def cmd_validate_ai_context() -> int: - templates = load_templates() - errors = [] - for tpl in templates: - if not tpl.ai_context: - errors.append(f"[{tpl.name}] Missing ai_context") - continue - cr = tpl.ai_context.cursor_rules - if not cr.tech_stack: - errors.append(f"[{tpl.name}] ai_context.cursor_rules.tech_stack is empty") - if not cr.conventions: - errors.append(f"[{tpl.name}] ai_context.cursor_rules.conventions is empty") - if not errors: - print(f"All {len(templates)} templates have valid AI context") - return EXIT_SUCCESS - for e in errors: - print(f" ! {e}") - return EXIT_ERROR - - -def cmd_validate(path: str) -> int: - tpl_path = Path(path).resolve() - if not tpl_path.exists(): - log_error(f"Path not found: {path}") - return EXIT_ERROR - - errors = [] - - if tpl_path.is_dir(): - tpl_file = tpl_path / "template.yaml" - if not tpl_file.exists(): - log_error(f"No template.yaml found in {path}") - return EXIT_ERROR - elif tpl_path.suffix in (".yaml", ".yml"): - tpl_file = tpl_path - else: - log_error(f"Invalid template path: {path}") - return EXIT_ERROR - - import yaml - try: - data = yaml.safe_load(tpl_file.read_text()) - except yaml.YAMLError as e: - log_error(f"Invalid YAML: {e}") - return EXIT_ERROR - - if not isinstance(data, dict): - log_error("Template must be a YAML mapping") - return EXIT_ERROR - - if "name" not in data: - errors.append("Missing required field: 'name'") - - ttype = data.get("type", "unknown") - from acorn.models import ProjectType - try: - ProjectType(ttype) - except ValueError: - errors.append(f"Invalid project type: '{ttype}'") - - ai_ctx = data.get("ai_context", {}) - if ai_ctx: - cursor = ai_ctx.get("cursor_rules", {}) - if not cursor.get("tech_stack"): - errors.append("ai_context.cursor_rules.tech_stack is recommended") - if not cursor.get("conventions"): - errors.append("ai_context.cursor_rules.conventions is recommended") - - provides = data.get("provides", []) - requires = data.get("requires", []) - overlap = set(provides) & set(requires) - if overlap: - errors.append(f"Capabilities in both provides and requires: {overlap}") - - if errors: - for e in errors: - print(f" ⚠ {e}") - return EXIT_ERROR - - print(f" ✓ Template '{data.get('name', '?')}' is valid") - return EXIT_SUCCESS - - -def cmd_completion(shell: str) -> int: - if shell == "bash": - print("""_acorn_completions() { - local cur prev opts - COMPREPLY=() - cur="${COMP_WORDS[COMP_CWORD]}" - opts="--version --wizard --template --list --add --remove --init --dir --with --dockerize --add-ci --analyze --allow-ai --clean --all --keep-templates --force --regenerate --dry-run --interactive --var --save --save-as --search --install --check-update --export --import --scan --validate --validate-ai-context --config --completion --telemetry-enable --telemetry-disable --telemetry-status --reset --lang --verbose --debug --quiet --json --offline --help" - COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) -} -complete -F _acorn_completions acorn""") - elif shell == "zsh": - print("""#compdef acorn -_acorn() { - local -a opts - opts=( - '--version[show version]' - '--wizard[interactive wizard]' - '--template[specify template]:template:->templates' - '--list[list templates]' - '--add[add template]:directory:_files -/' - '--remove[remove template]' - '--init[init project config]' - '--dir[target directory]:directory:_files -/' - '--with[compose templates]' - '--dockerize[generate Docker config]' - '--add-ci[generate CI config]' - '--analyze[analyze project]' - '--allow-ai[allow AI analysis]' - '--clean[clean generated files]' - '--all[clean all]' - '--keep-templates[keep templates]' - '--force[force overwrite]' - '--regenerate[regenerate with backup]' - '--dry-run[preview only]' - '--interactive[interactive mode]' - '--var[custom variable]:variable:' - '--save[save as template]' - '--save-as[save template as]:name:' - '--search[search templates]:query:' - '--install[install template]:repo:' - '--check-update[check for update]' - '--export[export config]:file:_files' - '--import[import config]:file:_files' - '--scan[scan for security]:path:_files -/' - '--validate[validate template]:path:_files -/' - '--validate-ai-context[validate AI context across all templates]' - '--config[config file]:file:_files' - '--completion[generate completion]:shell:(bash zsh fish)' - '--telemetry-enable[enable telemetry]' - '--telemetry-disable[disable telemetry]' - '--telemetry-status[telemetry status]' - '--reset[reset wizard]' - '--lang[language]:lang:(en zh)' - '--verbose[verbose output]' - '--debug[debug mode]' - '--quiet[quiet mode]' - '--json[JSON output]' - '--offline[offline mode]' - '--help[show help]' - ) - _describe 'acorn' opts -} -compdef _acorn acorn""") - elif shell == "fish": - print("""complete -c acorn -l version -d 'show version' -complete -c acorn -l wizard -d 'interactive wizard' -complete -c acorn -l template -d 'specify template' -complete -c acorn -l list -d 'list templates' -complete -c acorn -l add -d 'add template directory' -r -complete -c acorn -l remove -d 'remove template' -r -complete -c acorn -l init -d 'init project config' -complete -c acorn -l dir -d 'target directory' -r -complete -c acorn -l with -d 'compose templates' -r -complete -c acorn -l dockerize -d 'generate Docker config' -complete -c acorn -l add-ci -d 'generate CI config' -complete -c acorn -l analyze -d 'analyze project' -complete -c acorn -l allow-ai -d 'allow AI analysis' -complete -c acorn -l clean -d 'clean generated files' -complete -c acorn -l all -d 'clean all' -complete -c acorn -l keep-templates -d 'keep templates' -complete -c acorn -l force -d 'force overwrite' -complete -c acorn -l regenerate -d 'regenerate with backup' -complete -c acorn -l dry-run -d 'preview only' -complete -c acorn -l interactive -d 'interactive mode' -complete -c acorn -l var -d 'custom variable' -r -complete -c acorn -l save -d 'save as template' -complete -c acorn -l save-as -d 'save template as' -r -complete -c acorn -l search -d 'search templates' -r -complete -c acorn -l install -d 'install template' -r -complete -c acorn -l check-update -d 'check for update' -complete -c acorn -l export -d 'export config' -r -complete -c acorn -l import -d 'import config' -r -complete -c acorn -l scan -d 'scan for security issues' -r -complete -c acorn -l validate -d 'validate template' -r -complete -c acorn -l validate-ai-context -d 'validate AI context' -complete -c acorn -l config -d 'config file' -r -complete -c acorn -l completion -d 'generate completion script' -r -complete -c acorn -l telemetry-enable -d 'enable telemetry' -complete -c acorn -l telemetry-disable -d 'disable telemetry' -complete -c acorn -l telemetry-status -d 'telemetry status' -complete -c acorn -l reset -d 'reset wizard state' -complete -c acorn -l lang -d 'language' -x -a 'en zh' -complete -c acorn -l verbose -d 'verbose output' -complete -c acorn -l debug -d 'debug mode' -complete -c acorn -l quiet -d 'quiet mode' -complete -c acorn -l json -d 'JSON output' -complete -c acorn -l offline -d 'offline mode' -complete -c acorn -l help -d 'show help'""") - else: - print(f"Unsupported shell: {shell}. Supported: bash, zsh, fish") - return EXIT_ERROR - return EXIT_SUCCESS - - def main() -> int: if len(sys.argv) <= 1: - return cmd_wizard() + return cmd_doctor() if len(sys.argv) >= 2 and sys.argv[1] == "wizard": 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:] parser = build_parser() args = parser.parse_args() @@ -958,7 +169,6 @@ def main() -> int: if not config_path.exists(): log_error(f"Config file not found: {args.config}") return EXIT_ERROR - CONFIG_FILE = Path.home() / ".acorn" / "config.yaml" CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(str(config_path), str(CONFIG_FILE)) @@ -986,25 +196,15 @@ def main() -> int: if args.install: return cmd_install(args.install, dry_run=args.dry_run, offline=args.offline) if args.check_update: - result = check_pypi_version(offline=args.offline) - if result is None: - if args.offline: - log_warning("Offline mode — skipping update check") - else: - log_error("Failed to check for updates") - return EXIT_ERROR - print(f"Current version: {result['current']}") - print(f"Latest version: {result['latest']}") - if result["upgrade_available"]: - print(f"An upgrade is available! {result['url']}") - else: - print("You are up to date!") - return EXIT_SUCCESS + return cmd_check_update(offline=args.offline) if args.validate_ai_context: return cmd_validate_ai_context() if args.validate: return cmd_validate(args.validate) + if args.fix: + return cmd_fix(args) + if args.scan: scan_path_ = Path(args.scan).resolve() if not scan_path_.exists(): @@ -1018,18 +218,11 @@ def main() -> int: else: print("No security issues found.") return EXIT_SUCCESS + if args.export: - target_dir = Path(args.dir).resolve() - output_path = None - if args.export != "default": - output_path = Path(args.export).resolve() - export_config(target_dir, output=output_path) - return EXIT_SUCCESS + return cmd_export(args) if args.import_file: - target_dir = Path(args.dir).resolve() - source = Path(args.import_file).resolve() - result = import_config(target_dir, source) - return EXIT_SUCCESS if result else EXIT_ERROR + return cmd_import(args) if args.dockerize: return cmd_dockerize(args) @@ -1041,6 +234,8 @@ def main() -> int: return cmd_clean(args) if args.with_templates: + from acorn.composer import compose_and_generate + from acorn.models import GenerationOptions target_dir = Path(args.dir).resolve() options = GenerationOptions( force=args.force, dry_run=args.dry_run, regenerate=args.regenerate, diff --git a/src/acorn/commands/__init__.py b/src/acorn/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/acorn/commands/admin.py b/src/acorn/commands/admin.py new file mode 100644 index 0000000..864e562 --- /dev/null +++ b/src/acorn/commands/admin.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import argparse +import shutil +from pathlib import Path + +from acorn.check_update import check_pypi_version +from acorn.config import export_config, import_config +from acorn.format import EXIT_SUCCESS, EXIT_ERROR +from acorn.log import error as log_error, warning as log_warning + + +def cmd_completion(shell: str) -> int: + if shell == "bash": + print("""_acorn_completions() { + local cur prev opts + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + opts="--version --wizard --template --list --add --remove --init --dir --with --dockerize --add-ci --analyze --allow-ai --clean --all --keep-templates --force --regenerate --dry-run --interactive --var --save --save-as --search --install --check-update --export --import --scan --validate --validate-ai-context --config --completion --telemetry-enable --telemetry-disable --telemetry-status --reset --lang --verbose --debug --quiet --json --offline --help" + COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) +} +complete -F _acorn_completions acorn""") + elif shell == "zsh": + print("""#compdef acorn +_acorn() { + local -a opts + opts=( + '--version[show version]' + '--wizard[interactive wizard]' + '--template[specify template]:template:->templates' + '--list[list templates]' + '--add[add template]:directory:_files -/' + '--remove[remove template]' + '--init[init project config]' + '--dir[target directory]:directory:_files -/' + '--with[compose templates]' + '--dockerize[generate Docker config]' + '--add-ci[generate CI config]' + '--analyze[analyze project]' + '--allow-ai[allow AI analysis]' + '--clean[clean generated files]' + '--all[clean all]' + '--keep-templates[keep templates]' + '--force[force overwrite]' + '--regenerate[regenerate with backup]' + '--dry-run[preview only]' + '--interactive[interactive mode]' + '--var[custom variable]:variable:' + '--save[save as template]' + '--save-as[save template as]:name:' + '--search[search templates]:query:' + '--install[install template]:repo:' + '--check-update[check for update]' + '--export[export config]:file:_files' + '--import[import config]:file:_files' + '--scan[scan for security]:path:_files -/' + '--validate[validate template]:path:_files -/' + '--validate-ai-context[validate AI context across all templates]' + '--config[config file]:file:_files' + '--completion[generate completion]:shell:(bash zsh fish)' + '--telemetry-enable[enable telemetry]' + '--telemetry-disable[disable telemetry]' + '--telemetry-status[telemetry status]' + '--reset[reset wizard]' + '--lang[language]:lang:(en zh)' + '--verbose[verbose output]' + '--debug[debug mode]' + '--quiet[quiet mode]' + '--json[JSON output]' + '--offline[offline mode]' + '--help[show help]' + ) + _describe 'acorn' opts +} +compdef _acorn acorn""") + elif shell == "fish": + print("""complete -c acorn -l version -d 'show version' +complete -c acorn -l wizard -d 'interactive wizard' +complete -c acorn -l template -d 'specify template' +complete -c acorn -l list -d 'list templates' +complete -c acorn -l add -d 'add template directory' -r +complete -c acorn -l remove -d 'remove template' -r +complete -c acorn -l init -d 'init project config' +complete -c acorn -l dir -d 'target directory' -r +complete -c acorn -l with -d 'compose templates' -r +complete -c acorn -l dockerize -d 'generate Docker config' +complete -c acorn -l add-ci -d 'generate CI config' +complete -c acorn -l analyze -d 'analyze project' +complete -c acorn -l allow-ai -d 'allow AI analysis' +complete -c acorn -l clean -d 'clean generated files' +complete -c acorn -l all -d 'clean all' +complete -c acorn -l keep-templates -d 'keep templates' +complete -c acorn -l force -d 'force overwrite' +complete -c acorn -l regenerate -d 'regenerate with backup' +complete -c acorn -l dry-run -d 'preview only' +complete -c acorn -l interactive -d 'interactive mode' +complete -c acorn -l var -d 'custom variable' -r +complete -c acorn -l save -d 'save as template' +complete -c acorn -l save-as -d 'save template as' -r +complete -c acorn -l search -d 'search templates' -r +complete -c acorn -l install -d 'install template' -r +complete -c acorn -l check-update -d 'check for update' +complete -c acorn -l export -d 'export config' -r +complete -c acorn -l import -d 'import config' -r +complete -c acorn -l scan -d 'scan for security issues' -r +complete -c acorn -l validate -d 'validate template' -r +complete -c acorn -l validate-ai-context -d 'validate AI context' +complete -c acorn -l config -d 'config file' -r +complete -c acorn -l completion -d 'generate completion script' -r +complete -c acorn -l telemetry-enable -d 'enable telemetry' +complete -c acorn -l telemetry-disable -d 'disable telemetry' +complete -c acorn -l telemetry-status -d 'telemetry status' +complete -c acorn -l reset -d 'reset wizard state' +complete -c acorn -l lang -d 'language' -x -a 'en zh' +complete -c acorn -l verbose -d 'verbose output' +complete -c acorn -l debug -d 'debug mode' +complete -c acorn -l quiet -d 'quiet mode' +complete -c acorn -l json -d 'JSON output' +complete -c acorn -l offline -d 'offline mode' +complete -c acorn -l help -d 'show help'""") + else: + print(f"Unsupported shell: {shell}. Supported: bash, zsh, fish") + return EXIT_ERROR + return EXIT_SUCCESS + + +def cmd_check_update(offline: bool = False) -> int: + result = check_pypi_version(offline=offline) + if result is None: + if offline: + log_warning("Offline mode — skipping update check") + else: + log_error("Failed to check for updates") + return EXIT_ERROR + print(f"Current version: {result['current']}") + print(f"Latest version: {result['latest']}") + if result["upgrade_available"]: + print(f"An upgrade is available! {result['url']}") + else: + print("You are up to date!") + return EXIT_SUCCESS + + +def cmd_export(args: argparse.Namespace) -> int: + target_dir = Path(args.dir).resolve() + output_path = None + if args.export != "default": + output_path = Path(args.export).resolve() + export_config(target_dir, output=output_path) + return EXIT_SUCCESS + + +def cmd_import(args: argparse.Namespace) -> int: + target_dir = Path(args.dir).resolve() + source = Path(args.import_file).resolve() + result = import_config(target_dir, source) + return EXIT_SUCCESS if result else EXIT_ERROR diff --git a/src/acorn/commands/analyze_cmd.py b/src/acorn/commands/analyze_cmd.py new file mode 100644 index 0000000..2794799 --- /dev/null +++ b/src/acorn/commands/analyze_cmd.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from acorn.analyzer import AnalyzeOptions +from acorn.analyzer import analyze as ai_analyze +from acorn.format import color, EXIT_SUCCESS, EXIT_ERROR +from acorn.i18n import text as i18n_text +from acorn.log import error as log_error + + +def cmd_analyze(args: argparse.Namespace) -> int: + target_dir = Path(args.dir).resolve() + if not target_dir.is_dir(): + log_error(f"Directory '{args.dir}' does not exist") + return EXIT_ERROR + + options = AnalyzeOptions( + allow_ai=args.allow_ai, + dry_run=args.dry_run, + ) + result = ai_analyze(target_dir, options) + + if args.json: + from acorn.json_output import print_json + data = { + "source": result.source, + "project_type": result.detection.project_type.value if result.detection else None, + "confidence": result.detection.confidence if result.detection else 0, + "framework": result.detection.framework if result.detection and result.detection.framework else None, + "matched_template": result.detection.matched_template if result.detection and result.detection.matched_template else None, + "ai_suggestion": result.ai_suggestion, + } + print_json(data) + return EXIT_SUCCESS + + print(f"\n{color('Analysis Report', 'bold')}") + print("=" * 50) + if result.detection: + d = result.detection + project_label = i18n_text("detected", type=d.project_type.value, confidence=f"{d.confidence:.0%}") + print(f" {project_label}") + if d.framework: + print(f" {i18n_text('framework', name=d.framework)}") + if d.matched_template: + print(f" {i18n_text('template', name=d.matched_template)}") + if d.all_matches: + print(f"\n {color('All matches:', 'dim')}") + for ptype, src, score in d.all_matches: + print(f" - {ptype.value} ({src}, {score:.0%})") + + print(f"\n Source: {color(result.source, 'cyan')}") + if result.ai_suggestion: + print(f"\n {color('AI Suggestion:', 'bold')}") + print(f" {result.ai_suggestion}") + return EXIT_SUCCESS diff --git a/src/acorn/commands/clean.py b/src/acorn/commands/clean.py new file mode 100644 index 0000000..3567f67 --- /dev/null +++ b/src/acorn/commands/clean.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import argparse +import shutil +from pathlib import Path + +from acorn.config import load_manifest, load_project_lock, remove_from_manifest +from acorn.format import EXIT_SUCCESS, EXIT_ERROR +from acorn.log import error as log_error, info as log_info + + +def cmd_clean(args: argparse.Namespace) -> int: + target_dir = Path(args.dir).resolve() + if not target_dir.is_dir(): + log_error(f"Directory '{args.dir}' does not exist") + return EXIT_ERROR + + clean_all = args.all + keep_templates = args.keep_templates + + if clean_all: + acorn_dir = target_dir / ".acorn" + if acorn_dir.exists(): + if args.dry_run: + print(f" 🔍 Would remove: {acorn_dir}/") + else: + shutil.rmtree(acorn_dir) + print(f" ✓ Removed: {acorn_dir}/") + if not keep_templates: + manifest = load_manifest() + key = str(target_dir.resolve()) + if key in manifest: + if not args.dry_run: + remove_from_manifest(target_dir) + print(" ✓ Removed from manifest") + log_info("Clean all complete") + return EXIT_SUCCESS + + lock = load_project_lock(target_dir) + if not lock or "files" not in lock: + log_info("No lock file found — nothing to clean") + return EXIT_ERROR + + files = lock.get("files", []) + if not files: + log_info("No generated files recorded in lock") + return EXIT_ERROR + + removed = 0 + for rel_path in files: + f = target_dir / rel_path + if f.exists(): + if args.dry_run: + print(f" 🔍 Would remove: {rel_path}") + else: + f.unlink() + print(f" ✓ Removed: {rel_path}") + removed += 1 + + if removed == 0 and not args.dry_run: + log_info("No files to clean") + else: + log_info(f"Cleaned {removed} generated file(s)") + return EXIT_SUCCESS diff --git a/src/acorn/commands/docker.py b/src/acorn/commands/docker.py new file mode 100644 index 0000000..5f3f70e --- /dev/null +++ b/src/acorn/commands/docker.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from acorn.analysis.insights import analyze +from acorn.detector import detect_project_type +from acorn.format import EXIT_ERROR, EXIT_SUCCESS, color +from acorn.generators.builtin import DOCKER_FILES, generate_file_content +from acorn.log import error as log_error, info as log_info +from acorn.models import ProjectType + +CI_WORKFLOWS_DIR = ".github/workflows" + + +def cmd_dockerize(args: argparse.Namespace) -> int: + target_dir = Path(args.dir).resolve() + if not target_dir.is_dir(): + log_error(f"Directory '{args.dir}' does not exist") + return EXIT_ERROR + + detection = detect_project_type(target_dir) + if detection.project_type == ProjectType.UNKNOWN: + log_error("Cannot detect project type in target directory") + return EXIT_ERROR + + project_type = detection.project_type.value + insights = analyze(target_dir) + force = getattr(args, "force", False) + dry_run = getattr(args, "dry_run", False) + + log_info(f"Generating Docker configuration for {project_type} project...") + + generated = 0 + for file_type in sorted(DOCKER_FILES): + dest = target_dir / file_type + if dest.exists() and not force: + log_info(f"Skipping {file_type} (already exists)") + continue + + content = generate_file_content(file_type, project_type, detection=detection, insights=insights) + + if dry_run: + print(f" {color('Would generate:', 'dim')} {file_type}") + else: + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(content) + print(f" {color('✓', 'green')} Generated: {file_type}") + generated += 1 + + if generated == 0: + log_info("No Docker files generated (they may already exist)") + return EXIT_ERROR + log_info(f"Generated {generated} Docker file(s)") + return EXIT_SUCCESS + + +def _ci_setup_action(project_type: str) -> str: + setups = { + "node": "actions/setup-node@v4\n with:\n node-version: '20'", + "python": "actions/setup-python@v5\n with:\n python-version: '3.12'", + "go": "actions/setup-go@v5\n with:\n go-version: '1.22'", + "rust": "actions/setup-rust@v1\n with:\n toolchain: stable", + "java": "actions/setup-java@v4\n with:\n java-version: '21'\n distribution: temurin", + "php": "actions/setup-php@v5\n with:\n php-version: '8.3'", + } + return setups.get(project_type, "actions/setup-node@v4\n with:\n node-version: '20'") + + +def _ci_run_command(project_type: str) -> str: + commands = { + "node": "npm ci\n - run: npm test", + "python": "pip install -r requirements.txt\n - run: pytest", + "go": "go mod download\n - run: go test ./...", + "rust": "cargo build\n - run: cargo test", + "java": "./mvnw verify", + "php": "composer install\n - run: phpunit", + } + return commands.get(project_type, "echo 'Run tests'") + + +def _generate_ci_yml(project_type: str) -> str: + setup = _ci_setup_action(project_type) + run = _ci_run_command(project_type) + return f"""name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup + uses: {setup} + - name: Install dependencies + run: {run.split(chr(10))[0]} + - name: Test + run: {run.split(chr(10))[-1].replace('- run: ', '') if chr(10) in run else run} +""" + + +def cmd_add_ci(args: argparse.Namespace) -> int: + target_dir = Path(args.dir).resolve() + if not target_dir.is_dir(): + log_error(f"Directory '{args.dir}' does not exist") + return EXIT_ERROR + + result = detect_project_type(target_dir) + if result.project_type == ProjectType.UNKNOWN: + log_error("Cannot detect project type") + return EXIT_ERROR + + project_type = result.project_type.value + workflows_dir = target_dir / CI_WORKFLOWS_DIR + workflows_dir.mkdir(parents=True, exist_ok=True) + + files_to_generate = ["ci.yml"] + generated = [] + + for fname in files_to_generate: + dest = workflows_dir / fname + if dest.exists() and not args.force: + log_info(f"Skipping {CI_WORKFLOWS_DIR}/{fname} (already exists)") + continue + + content = _generate_ci_yml(project_type) + + if args.dry_run: + print(f" 🔍 Would generate: {CI_WORKFLOWS_DIR}/{fname}") + continue + + dest.write_text(content) + print(f" ✓ Generated: {CI_WORKFLOWS_DIR}/{fname}") + generated.append(dest) + + if not generated: + log_info("No CI files generated") + return EXIT_ERROR + log_info(f"Generated {len(generated)} CI workflow file(s)") + return EXIT_SUCCESS diff --git a/src/acorn/commands/doctor.py b/src/acorn/commands/doctor.py new file mode 100644 index 0000000..c13fd59 --- /dev/null +++ b/src/acorn/commands/doctor.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from acorn.analysis.health import diagnose +from acorn.analysis.health_rules import CheckCategory, CheckPriority +from acorn.analysis.insights import has_source_code +from acorn.config import load_config +from acorn.format import color, confirm_or_exit, EXIT_SUCCESS +from acorn.i18n import detect_language, set_language, t +from acorn.log import info as log_info + + +def _msg(key: str, fallback: str = "") -> str: + val = t(key) + return val if val != key else fallback + + +def _display_report(report) -> None: + name = report.project_path.name + title = _msg("messages.doctor_title", f"Project Report — {name}") + print(f"\n{color(title, 'bold')}") + + type_str = f"{report.project_type}" + if report.framework: + type_str += f" ({report.framework})" + print(f" {_msg('messages.project_type', 'Type')}: {type_str} ({_msg('messages.confidence', 'confidence')}: {report.confidence:.0%})") + + sections = [ + (CheckCategory.AI_READINESS, "messages.ai_readiness", "🤖"), + (CheckCategory.DEVOPS, "messages.devops", "🐳"), + (CheckCategory.CODE_QUALITY, "messages.code_quality", "📋"), + ] + + for cat, key, icon in sections: + cat_checks = [c for c in report.checks if c.category == cat] + if not cat_checks: + continue + print(f"\n {icon} {_msg(key, key)}") + for c in cat_checks: + mark = color("✓", "green") if c.status else color("✗", "red") + label = f"check_{c.name}_present" if c.status else f"check_{c.name}_absent" + msg = _msg(f"messages.{label}", "") + print(f" {mark} {c.name:<30} {msg}") + if not c.status and c.fix_target and c.auto_fixable: + print(f" [{color('acorn fix --' + c.fix_target, 'dim')}]") + + print(f"\n {'═' * 50}") + s = report.summary + print(f" {color('✓', 'green')} {s['passed']} {_msg('messages.passed', 'passed')} " + f"{color('✗', 'red')} {s['failed']} {_msg('messages.failed', 'failed')}") + + +def cmd_doctor() -> int: + cwd = Path.cwd() + + if not has_source_code(cwd): + from acorn.wizard import cmd_wizard + return cmd_wizard() + + config = load_config() + lang = config.get("default_lang", "en") + lang = detect_language(lang) + set_language(lang) + + from acorn.telemetry import maybe_prompt + maybe_prompt() + + report = diagnose(cwd) + + if "--json" in sys.argv or "-j" in sys.argv: + import json + print(json.dumps(report.to_dict(), ensure_ascii=False, indent=2)) + return EXIT_SUCCESS + + _display_report(report) + + failed_auto = [c for c in report.checks if not c.status and c.auto_fixable] + if failed_auto: + prompt_text = _msg("messages.fix_prompt", "Fix all auto-fixable items?") + if confirm_or_exit(prompt_text): + from acorn.commands.fix import fix_all + scope = {c.fix_target for c in failed_auto if c.fix_target} + return fix_all(cwd, scope=scope) if scope else EXIT_SUCCESS + + return EXIT_SUCCESS diff --git a/src/acorn/commands/fix.py b/src/acorn/commands/fix.py new file mode 100644 index 0000000..bf0a2e9 --- /dev/null +++ b/src/acorn/commands/fix.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from acorn.analysis.detector import detect_project_type +from acorn.analysis.insights import analyze +from acorn.detector import detect_project_type as detector_detect +from acorn.format import EXIT_ERROR, EXIT_SUCCESS, color, confirm_or_exit +from acorn.generators.builtin import DOCKER_FILES, generate_file_content +from acorn.i18n import text as i18n_text +from acorn.log import debug as log_debug, error as log_error, info as log_info + +GENERATABLE_FILES = { + "dockerfile": {"file_type": "Dockerfile", "dest_name": "Dockerfile"}, + "dockerignore": {"file_type": ".dockerignore", "dest_name": ".dockerignore"}, + "gitignore": {"file_type": ".gitignore", "dest_name": ".gitignore"}, + "cursorrules": {"file_type": ".cursorrules", "dest_name": ".cursorrules"}, + "claude-md": {"file_type": "CLAUDE.md", "dest_name": "CLAUDE.md"}, + "copilot": {"file_type": ".github/copilot-instructions.md", "dest_name": ".github/copilot-instructions.md"}, +} + +AI_TARGETS = {"cursorrules", "claude-md", "copilot"} +DOCKER_TARGETS = {"dockerfile", "dockerignore"} + + +def _write_file(dest: Path, content: str, force: bool = False, dry_run: bool = False) -> bool: + if dest.exists() and not force: + log_info(i18n_text("fix_skipped", name=str(dest))) + return False + + if dry_run: + print(f" {color('Would generate:', 'dim')} {dest}") + return True + + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(content) + print(f" {color('✓', 'green')} {i18n_text('fix_generated', name=str(dest))}") + return True + + +def cmd_fix_individual(target: str, cwd: Path, detection=None, insights=None, force: bool = False, dry_run: bool = False) -> int: + info = GENERATABLE_FILES.get(target) + if info is None: + log_error(f"Unknown fix target: {target}") + return EXIT_ERROR + + if detection is None: + detection = detect_project_type(cwd) + if insights is None: + insights = analyze(cwd) + + project_type = detection.project_type.value if detection else "unknown" + content = generate_file_content(info["file_type"], project_type, detection=detection, insights=insights) + dest = cwd / info["dest_name"] + + _write_file(dest, content, force=force, dry_run=dry_run) + return EXIT_SUCCESS + + +def fix_all(cwd: Path, detection=None, insights=None, scope: set[str] | None = None, force: bool = False, dry_run: bool = False) -> int: + if detection is None: + detection = detect_project_type(cwd) + if insights is None: + insights = analyze(cwd) + + targets = scope if scope is not None else set(GENERATABLE_FILES.keys()) + success = 0 + for target in sorted(targets): + if target in GENERATABLE_FILES: + rc = cmd_fix_individual(target, cwd, detection, insights, force=force, dry_run=dry_run) + if rc == EXIT_SUCCESS: + success += 1 + + if not dry_run: + log_info(f"Fixed {success}/{len(targets)} items") + return EXIT_SUCCESS if success > 0 else EXIT_ERROR + + +def cmd_fix(args: argparse.Namespace) -> int: + cwd = Path(args.dir).resolve() if hasattr(args, "dir") else Path.cwd() + + detection = None + insights = None + + targets: set[str] = set() + + if hasattr(args, "fix_dockerfile") and args.fix_dockerfile: + targets.add("dockerfile") + if hasattr(args, "fix_dockerignore") and args.fix_dockerignore: + targets.add("dockerignore") + if hasattr(args, "fix_gitignore") and args.fix_gitignore: + targets.add("gitignore") + if hasattr(args, "fix_cursorrules") and args.fix_cursorrules: + targets.add("cursorrules") + if hasattr(args, "fix_claude_md") and args.fix_claude_md: + targets.add("claude-md") + if hasattr(args, "fix_copilot") and args.fix_copilot: + targets.add("copilot") + if hasattr(args, "fix_ai") and args.fix_ai: + targets |= AI_TARGETS + if hasattr(args, "fix_all") and args.fix_all: + targets |= set(GENERATABLE_FILES.keys()) + + force = getattr(args, "force", False) + dry_run = getattr(args, "dry_run", False) + + if not targets: + log_error("No fix targets specified. Use --dockerfile, --cursorrules, --ai, --all, etc.") + return EXIT_ERROR + + detection = detect_project_type(cwd) + insights = analyze(cwd) + + return fix_all(cwd, detection=detection, insights=insights, scope=targets, force=force, dry_run=dry_run) diff --git a/src/acorn/commands/generate.py b/src/acorn/commands/generate.py new file mode 100644 index 0000000..3f49c5c --- /dev/null +++ b/src/acorn/commands/generate.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from acorn.config import ( + load_project_config, + load_templates, +) +from acorn.detector import detect_mixed_project, detect_project_type +from acorn.format import color, suggest_help, EXIT_SUCCESS, EXIT_ERROR, EXIT_NO_MATCH +from acorn.i18n import cmd_text, error as i18n_error, prompt as i18n_prompt, text as i18n_text +from acorn.log import debug as log_debug, error as log_error, info as log_info +from acorn.models import GenerationOptions, ProjectType +from acorn.template_engine import auto_generate, generate_from_template, list_templates, save_as_template_from_project + + +def _handle_mixed_project(target_dir: Path, options: GenerationOptions) -> str | None: + matches = detect_mixed_project(target_dir) + if len(matches) <= 1: + return None + + print(f"\n{color('Detected multiple project types:', 'yellow')}") + for i, (ptype, src, score) in enumerate(matches[:5], 1): + print(f" [{i}] {color(ptype.value, 'cyan')} ({src}, confidence: {score:.0%})") + print(" [0] Cancel") + + if not options.interactive: + best = matches[0] + log_debug(f"Auto-selecting best match: {best[0].value}") + return best[0].value + + try: + choice = input(f"\n{color('?', 'blue')} Select (1-{min(len(matches), 5)}): ").strip() + if choice.isdigit(): + idx = int(choice) - 1 + if 0 <= idx < len(matches): + return matches[idx][0].value + except (EOFError, KeyboardInterrupt): + pass + return None + + +def cmd_generate(args: argparse.Namespace) -> int: + target_dir = Path(args.dir).resolve() + if not target_dir.is_dir(): + log_error(i18n_error("dir_not_exist", dir=args.dir) + suggest_help()) + return EXIT_ERROR + + options = GenerationOptions( + force=args.force, + dry_run=args.dry_run, + interactive=args.interactive, + regenerate=args.regenerate, + template_name=args.template, + save=args.save or bool(args.save_as), + verbose=args.verbose, + debug=args.debug, + quiet=args.quiet, + offline=args.offline, + lang=args.lang, + ) + + if args.var: + for v in args.var: + if "=" in v: + key, val = v.split("=", 1) + options.variables[key.strip()] = val.strip() + + if args.template: + generated = generate_from_template(args.template, target_dir, options) + if options.save and not options.dry_run: + save_name = args.save_as or target_dir.name + save_as_template_from_project(target_dir, name=save_name, dry_run=args.dry_run) + if options.dry_run: + return EXIT_SUCCESS + return EXIT_SUCCESS if generated else EXIT_ERROR + + project_config = load_project_config(target_dir) + if "template" in project_config and not args.template: + options.template_name = project_config["template"] + log_info(f"Using project-config template: {options.template_name}") + generated = generate_from_template(options.template_name, target_dir, options) + if options.dry_run: + return EXIT_SUCCESS + return EXIT_SUCCESS if generated else EXIT_ERROR + + is_empty = len(list(target_dir.iterdir())) <= 1 + + if is_empty: + log_info(cmd_text("empty_project")) + print(f"\n{color(cmd_text('empty_project'), 'yellow')}") + if args.interactive: + templates = load_templates() + print(f"\n{color(cmd_text('list_title', count=str(len(templates))), 'bold')}") + for i, t in enumerate(templates, 1): + print(f" [{i}] {t.name}: {t.description}") + print(" [a] Auto-generate") + + try: + choice = input(f"\n{color('?', 'blue')} Select: ").strip().lower() + except (EOFError, KeyboardInterrupt): + choice = "a" + + if choice.isdigit(): + idx = int(choice) - 1 + if 0 <= idx < len(templates): + generate_from_template(templates[idx].name, target_dir, options) + return EXIT_SUCCESS + auto_generate("unknown", target_dir, options) + return EXIT_SUCCESS + else: + log_info("Run with --interactive or --template") + return EXIT_NO_MATCH + + log_info(i18n_text("detecting", path=str(target_dir))) + print(f"{color(cmd_text('scanning'), 'bold')} in {target_dir}...") + result = detect_project_type(target_dir) + + mixed_selection = _handle_mixed_project(target_dir, options) + + if result.project_type != ProjectType.UNKNOWN: + confidence_display = f"{result.confidence:.0%}" + detected_msg = i18n_text("detected", type=result.project_type.value, confidence=confidence_display) + print(f"\n{color('√', 'green')} {detected_msg}") + if result.framework: + print(f" {i18n_text('framework', name=result.framework)}") + if result.matched_template: + print(f" {i18n_text('template', name=result.matched_template)} #{color(result.project_type.value, 'cyan')}") + if "detected_port" in result.details: + port_msg = i18n_text("port", port=result.details["detected_port"]) + print(f" {port_msg}") + log_debug(f"Detection details: {result.details}") + else: + print(f"\n{color(i18n_text('not_detected'), 'yellow')}") + + if result.matched_template: + if args.interactive: + from acorn.format import confirm_or_exit + if not confirm_or_exit(i18n_prompt("confirm_template", name=result.matched_template)): + templates = load_templates() + print(f"\n{color(i18n_prompt('select_template'), 'bold')}") + for i, t in enumerate(templates, 1): + print(f" [{i}] {t.name}: {t.description}") + try: + choice = input(f"\n{color('?', 'blue')} Select: ").strip().lower() + except (EOFError, KeyboardInterrupt): + choice = "" + if choice.isdigit(): + idx = int(choice) - 1 + if 0 <= idx < len(templates): + generate_from_template(templates[idx].name, target_dir, options) + return EXIT_SUCCESS + + print() + generated = generate_from_template(result.matched_template, target_dir, options) + if options.save and not options.dry_run: + save_name = args.save_as or target_dir.name + save_as_template_from_project(target_dir, name=save_name, dry_run=args.dry_run) + if options.dry_run: + return EXIT_SUCCESS + return EXIT_SUCCESS if generated else EXIT_NO_MATCH + else: + project_type_str = mixed_selection or ( + result.project_type.value if result.project_type != ProjectType.UNKNOWN else "unknown" + ) + + if args.interactive: + templates = load_templates() + title = i18n_prompt("select_template") + print(f"\n{color(title, 'bold')}") + for i, t in enumerate(templates, 1): + print(f" [{i}] {t.name}: {t.description}") + print(f" [a] Auto-generate for {project_type_str}") + print(" [s] Skip") + + try: + choice = input(f"\n{color('?', 'blue')} {i18n_prompt('select_option')}: ").strip().lower() + except (EOFError, KeyboardInterrupt): + choice = "s" + + if choice.isdigit(): + idx = int(choice) - 1 + if 0 <= idx < len(templates): + generate_from_template(templates[idx].name, target_dir, options) + return EXIT_SUCCESS + log_info("Skipped.") + return EXIT_NO_MATCH + elif choice == "a": + auto_generate(project_type_str, target_dir, options) + return EXIT_SUCCESS + else: + log_info("Skipped.") + return EXIT_NO_MATCH + else: + log_info(i18n_text("no_match")) + return EXIT_NO_MATCH diff --git a/src/acorn/commands/marketplace.py b/src/acorn/commands/marketplace.py new file mode 100644 index 0000000..f77992e --- /dev/null +++ b/src/acorn/commands/marketplace.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from acorn.format import color, EXIT_SUCCESS, EXIT_ERROR, EXIT_NO_MATCH +from acorn.i18n import cmd_text +from acorn.log import error as log_error, info as log_info, warning as log_warning +from acorn.marketplace import install_from_github, search_all, search_github + + +def cmd_search(query: str, offline: bool = False) -> int: + if offline: + log_warning("Offline mode - skipping search") + return EXIT_ERROR + + print(f"{color('Searching for:', 'bold')} {query}") + results = search_all(query) + if not results: + results = search_github(query) + + if not results: + log_info(f"No results found for '{query}'") + return EXIT_NO_MATCH + + print(f"\n{color(cmd_text('search_results', query=query), 'bold')}") + print("-" * 60) + for r in results: + stars = color(f"★{r['stars']}", "yellow") if r["stars"] > 0 else "" + print(f" {color(r['full_name'], 'cyan')} {stars}") + if r["description"]: + print(f" {r['description'][:70]}") + print() + return EXIT_SUCCESS + + +def cmd_install(repo: str, dry_run: bool = False, offline: bool = False) -> int: + if offline: + log_warning("Offline mode - skipping install") + return EXIT_ERROR + + if "/" not in repo: + log_error(f"Invalid repo format '{repo}'. Use user/repo format.") + return EXIT_ERROR + + log_info(f"Installing template from {repo}") + result = install_from_github(repo, dry_run=dry_run) + if result: + print(f"{color('✓', 'green')} Template installed from {repo}") + return EXIT_SUCCESS + return EXIT_ERROR diff --git a/src/acorn/commands/template_cmd.py b/src/acorn/commands/template_cmd.py new file mode 100644 index 0000000..2bfefb9 --- /dev/null +++ b/src/acorn/commands/template_cmd.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +import argparse +import shutil +from pathlib import Path +from shutil import copytree, ignore_patterns + +import yaml + +from acorn.config import ( + TEMPLATES_DIR, + ensure_dirs, + init_project_config, + load_templates, +) +from acorn.format import color, EXIT_SUCCESS, EXIT_ERROR +from acorn.i18n import cmd_text +from acorn.log import error as log_error, info as log_info +from acorn.models import ProjectType +from acorn.template_engine import list_templates + + +def cmd_list(json_mode: bool = False) -> int: + templates = list_templates() + if not templates: + log_info("No templates found.") + return EXIT_SUCCESS + + if json_mode: + from acorn.json_output import print_json + print_json({"templates": templates, "count": len(templates)}) + return EXIT_SUCCESS + + title = cmd_text("list_title", count=str(len(templates))) + print(f"\n{color(title, 'bold')}") + print("-" * 60) + for t in templates: + name = color(t["name"], "cyan") + print(f" {name:<20} {t['description']:<30} v{t['version']}") + if t["files"]: + print(f" {'':>20} files: {', '.join(t['files'])}") + print() + return EXIT_SUCCESS + + +def cmd_add(path: str) -> int: + src = Path(path).resolve() + if not src.is_dir(): + log_error(f"'{path}' is not a valid directory") + return EXIT_ERROR + + template_yaml = src / "template.yaml" + if not template_yaml.exists(): + log_error(f"No template.yaml found at {path}") + return EXIT_ERROR + + dest = TEMPLATES_DIR / src.name + if dest.exists(): + log_error(f"Template '{src.name}' already exists") + return EXIT_ERROR + + ensure_dirs() + copytree(src, dest, ignore=ignore_patterns("__pycache__", ".git")) + log_info(f"Template '{src.name}' added to {dest}") + print(f"{color('✓', 'green')} Template '{src.name}' added") + return EXIT_SUCCESS + + +def cmd_remove(name: str) -> int: + dest = TEMPLATES_DIR / name + if not dest.exists(): + log_error(f"Template '{name}' not found") + return EXIT_ERROR + + shutil.rmtree(dest) + log_info(f"Template '{name}' removed") + print(f"{color('✓', 'green')} Template '{name}' removed") + return EXIT_SUCCESS + + +def cmd_init(args: argparse.Namespace) -> int: + target_dir = Path(args.dir).resolve() + if not target_dir.is_dir(): + log_error(f"Directory '{args.dir}' does not exist") + return EXIT_ERROR + + config = init_project_config(target_dir, template_name=args.template, dry_run=args.dry_run) + return EXIT_SUCCESS if config else EXIT_ERROR + + +def cmd_validate(path: str) -> int: + tpl_path = Path(path).resolve() + if not tpl_path.exists(): + log_error(f"Path not found: {path}") + return EXIT_ERROR + + errors = [] + + if tpl_path.is_dir(): + tpl_file = tpl_path / "template.yaml" + if not tpl_file.exists(): + log_error(f"No template.yaml found in {path}") + return EXIT_ERROR + elif tpl_path.suffix in (".yaml", ".yml"): + tpl_file = tpl_path + else: + log_error(f"Invalid template path: {path}") + return EXIT_ERROR + + try: + data = yaml.safe_load(tpl_file.read_text()) + except yaml.YAMLError as e: + log_error(f"Invalid YAML: {e}") + return EXIT_ERROR + + if not isinstance(data, dict): + log_error("Template must be a YAML mapping") + return EXIT_ERROR + + if "name" not in data: + errors.append("Missing required field: 'name'") + + ttype = data.get("type", "unknown") + try: + ProjectType(ttype) + except ValueError: + errors.append(f"Invalid project type: '{ttype}'") + + ai_ctx = data.get("ai_context", {}) + if ai_ctx: + cursor = ai_ctx.get("cursor_rules", {}) + if not cursor.get("tech_stack"): + errors.append("ai_context.cursor_rules.tech_stack is recommended") + if not cursor.get("conventions"): + errors.append("ai_context.cursor_rules.conventions is recommended") + + provides = data.get("provides", []) + requires = data.get("requires", []) + overlap = set(provides) & set(requires) + if overlap: + errors.append(f"Capabilities in both provides and requires: {overlap}") + + if errors: + for e in errors: + print(f" ⚠ {e}") + return EXIT_ERROR + + print(f" ✓ Template '{data.get('name', '?')}' is valid") + return EXIT_SUCCESS + + +def cmd_validate_ai_context() -> int: + templates = load_templates() + errors = [] + for tpl in templates: + if not tpl.ai_context: + errors.append(f"[{tpl.name}] Missing ai_context") + continue + cr = tpl.ai_context.cursor_rules + if not cr.tech_stack: + errors.append(f"[{tpl.name}] ai_context.cursor_rules.tech_stack is empty") + if not cr.conventions: + errors.append(f"[{tpl.name}] ai_context.cursor_rules.conventions is empty") + if not errors: + print(f"All {len(templates)} templates have valid AI context") + return EXIT_SUCCESS + for e in errors: + print(f" ! {e}") + return EXIT_ERROR diff --git a/src/acorn/detector.py b/src/acorn/detector.py index 5f31128..bfd8a25 100644 --- a/src/acorn/detector.py +++ b/src/acorn/detector.py @@ -1,341 +1,41 @@ from __future__ import annotations -import re -from pathlib import Path - -import yaml - -from acorn.config import ( - load_detector_rules, - load_templates, - resolve_template, +from acorn.analysis.detector import ( + IGNORE_DIRS, + MANIFEST_MAP, + ENTRY_FILE_PATTERNS, + _read_file_safe, + _find_files_recursive, + _has_files, + _check_content_recursive, + _check_dependencies, + _check_patterns, + _detect_by_manifest, + _find_entry_files, + _detect_port, + evaluate_rule, + detect_project_type, + detect_mixed_project, + _check_indicator, + evaluate_template_match, ) -from acorn.models import ( - DetectionResult, - DetectorRule, - ProjectType, - Template, -) - -IGNORE_DIRS = { - ".git", "node_modules", "__pycache__", ".venv", "venv", - "target", "build", "dist", ".next", ".nuxt", - ".idea", ".vscode", ".DS_Store", -} - -MANIFEST_MAP: dict[str, ProjectType] = { - "package.json": ProjectType.NODE, - "pyproject.toml": ProjectType.PYTHON, - "requirements.txt": ProjectType.PYTHON, - "setup.py": ProjectType.PYTHON, - "setup.cfg": ProjectType.PYTHON, - "Pipfile": ProjectType.PYTHON, - "go.mod": ProjectType.GO, - "Cargo.toml": ProjectType.RUST, - "pom.xml": ProjectType.JAVA, - "build.gradle": ProjectType.JAVA, - "build.gradle.kts": ProjectType.JAVA, - "Gemfile": ProjectType.RUBY, - "composer.json": ProjectType.PHP, - "deno.json": ProjectType.DENO, - "deno.jsonc": ProjectType.DENO, - "bun.lockb": ProjectType.BUN, - "bun.lock": ProjectType.BUN, -} - -ENTRY_FILE_PATTERNS: dict[ProjectType, list[str]] = { - ProjectType.NODE: ["index.js", "index.ts", "app.js", "app.ts", "server.js", "server.ts", "main.js"], - ProjectType.PYTHON: ["main.py", "app.py", "wsgi.py", "manage.py"], - ProjectType.GO: ["main.go", "cmd/server/main.go"], - ProjectType.RUST: ["src/main.rs"], - ProjectType.JAVA: ["src/main/java/**/Application.java", "src/main/java/**/App.java"], - ProjectType.RUBY: ["app.rb", "config.ru", "bin/rails"], - ProjectType.PHP: ["public/index.php", "index.php", "artisan"], -} - - -def _read_file_safe(path: Path) -> str | None: - try: - if path.is_file(): - return path.read_text(encoding="utf-8", errors="ignore") - except OSError: - return None - return None - - -def _find_files_recursive(dir_path: Path, patterns: list[str]) -> list[Path]: - results: list[Path] = [] - for pattern in patterns: - for f in dir_path.rglob(pattern): - parts = f.relative_to(dir_path).parts - if any(part in IGNORE_DIRS for part in parts): - continue - results.append(f) - return results - - -def _has_files(dir_path: Path, filenames: list[str]) -> bool: - return any((dir_path / f).exists() for f in filenames) - - -def _check_content_recursive(dir_path: Path, keywords: list[str]) -> int: - found = 0 - for f in dir_path.rglob("*"): - if not f.is_file(): - continue - parts = f.relative_to(dir_path).parts - if any(part in IGNORE_DIRS for part in parts): - continue - content = _read_file_safe(f) - if content: - found += sum(1 for kw in keywords if kw in content) - return found - - -def _check_dependencies(dir_path: Path, deps: list[str]) -> bool: - for manifest_name in MANIFEST_MAP: - manifest = dir_path / manifest_name - content = _read_file_safe(manifest) - if content: - if all(d in content for d in deps): - return True - return False - - -def _check_patterns(dir_path: Path, patterns: list[str]) -> int: - matched = 0 - for pattern in patterns: - if _find_files_recursive(dir_path, [pattern]): - matched += 1 - return matched - - -def _detect_by_manifest(dir_path: Path) -> ProjectType | None: - for manifest, ptype in MANIFEST_MAP.items(): - if (dir_path / manifest).exists(): - return ptype - return None - - -def _find_entry_files(dir_path: Path) -> list[tuple[str, ProjectType]]: - found: list[tuple[str, ProjectType]] = [] - for ptype, patterns in ENTRY_FILE_PATTERNS.items(): - for pattern in patterns: - if "**" in pattern: - matches = list(dir_path.glob(pattern)) - if matches: - found.append((matches[0].name, ptype)) - else: - f = dir_path / pattern - if f.exists(): - found.append((pattern, ptype)) - return found - - -def _detect_port(dir_path: Path, project_type: ProjectType) -> str | None: - if project_type == ProjectType.NODE: - for pattern in ["*.js", "*.ts"]: - for f in _find_files_recursive(dir_path, [pattern]): - content = _read_file_safe(f) - if content: - m = re.search(r'(?:listen|port)\s*[=:(]\s*(\d{4,5})', content) - if m: - return m.group(1) - elif project_type == ProjectType.PYTHON: - for pattern in ["*.py"]: - for f in _find_files_recursive(dir_path, [pattern]): - content = _read_file_safe(f) - if content: - m = re.search(r'port\s*=\s*(\d{4,5})', content) - if m: - return m.group(1) - return None - - -def evaluate_rule(rule: DetectorRule, dir_path: Path) -> float: - score = 0.0 - max_score = 0.0 - - c = rule.conditions - - if c.files: - max_score += 35.0 - found_files = sum(1 for f in c.files if (dir_path / f).exists()) - if found_files > 0: - score += 35.0 * (found_files / len(c.files)) - - if c.content: - max_score += 30.0 - found_count = _check_content_recursive(dir_path, c.content) - if found_count > 0: - score += 30.0 * min(found_count / len(c.content), 1.0) - - if c.dependencies: - max_score += 20.0 - if _check_dependencies(dir_path, c.dependencies): - score += 20.0 - - if c.patterns: - max_score += 15.0 - matched = _check_patterns(dir_path, c.patterns) - if matched > 0: - score += 15.0 * min(matched / len(c.patterns), 1.0) - - return score / max_score if max_score > 0 else 0.0 - - -def detect_project_type(dir_path: Path | str) -> DetectionResult: - if isinstance(dir_path, str): - dir_path = Path(dir_path).resolve() - if not dir_path.is_dir(): - return DetectionResult(project_type=ProjectType.UNKNOWN, details={"error": "path is not a directory"}) - - manifest_type = _detect_by_manifest(dir_path) - - rules = load_detector_rules() - templates = load_templates() - - all_matches: list[tuple[ProjectType, str, float]] = [] - best_score = 0.0 - result = DetectionResult() - - if manifest_type: - all_matches.append((manifest_type, "manifest", 0.3)) - result.project_type = manifest_type - result.confidence = 0.3 - best_score = 0.3 - - for rule in rules: - score = evaluate_rule(rule, dir_path) - if score > 0: - all_matches.append((rule.type, f"rule:{rule.name}", score)) - if score > best_score: - best_score = score - result.project_type = rule.type - result.confidence = score - result.framework = None - result.matched_template = None - if rule.indicators: - for indicator in rule.indicators: - if _check_indicator(indicator.check_expression, dir_path): - result.framework = indicator.name - break - - for template in templates: - resolved = resolve_template(template) - score = evaluate_template_match(resolved, dir_path) - if score > 0: - all_matches.append((resolved.project_type, f"template:{template.name}", score)) - if score > best_score: - best_score = score - result.project_type = resolved.project_type - result.matched_template = template.name - result.confidence = score - - if result.matched_template is None and result.project_type != ProjectType.UNKNOWN: - best_tpl_score = 0.0 - best_tpl_name: str | None = None - for template in templates: - resolved = resolve_template(template) - if resolved.project_type != result.project_type.value: - continue - score = evaluate_template_match(resolved, dir_path) - if score > best_tpl_score: - best_tpl_score = score - best_tpl_name = template.name - if best_tpl_name is not None: - result.matched_template = best_tpl_name - - entry_files = _find_entry_files(dir_path) - for entry_name, etype in entry_files: - if etype not in [m[0] for m in all_matches]: - all_matches.append((etype, f"entry:{entry_name}", 0.25)) - if 0.25 > best_score: - best_score = 0.25 - result.project_type = etype - - detected_port = _detect_port(dir_path, result.project_type) - - result.confidence = round(best_score, 2) - all_matches.sort(key=lambda m: m[2], reverse=True) - result.all_matches = [(pt, src, round(sc, 2)) for pt, src, sc in all_matches] - - files_found = [] - for f in sorted(dir_path.iterdir())[:20]: - if f.is_file(): - files_found.append(f.name) - result.details["files_found"] = files_found - result.details["entry_files"] = [e[0] for e in entry_files] - if detected_port: - result.details["detected_port"] = detected_port - - return result - - -def detect_mixed_project(dir_path: Path | str) -> list[tuple[ProjectType, str, float]]: - if isinstance(dir_path, str): - dir_path = Path(dir_path).resolve() - result = detect_project_type(dir_path) - return result.all_matches[:5] - - -def _check_indicator(expression: str, dir_path: Path) -> bool: - parts = expression.split("&&") - for part in parts: - part = part.strip() - if " in " in part: - left, right = part.split(" in ", 1) - left = left.strip() - right = right.strip().strip("'\"") - if left.startswith("dependencies."): - dep_name = left[len("dependencies.") :] - pkg_file = dir_path / right - content = _read_file_safe(pkg_file) - if content: - try: - pkg = yaml.safe_load(content) - if isinstance(pkg, dict): - deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})} - if dep_name not in deps: - return False - else: - return False - except Exception: - return False - else: - return False - elif "." in right: - target_file = dir_path / right - content = _read_file_safe(target_file) - if content is None or left.strip("'\"") not in content: - return False - else: - return False - elif "==" in part or "contains" in part: - pass - else: - content = _read_file_safe(dir_path / part.strip()) - if content is None: - return False - return True - - -def evaluate_template_match(template: Template, dir_path: Path) -> float: - score = 0.0 - max_score = 0.0 - - d = template.detectors - - if d.files: - max_score += 50.0 - found = sum(1 for f in d.files if (dir_path / f).exists()) - if found > 0: - score += 50.0 * (found / len(d.files)) - - if d.keywords: - max_score += 50.0 - found = _check_content_recursive(dir_path, d.keywords) - if found > 0: - score += 50.0 * min(found / len(d.keywords), 1.0) - return score / max_score if max_score > 0 else 0.0 +__all__ = [ + "IGNORE_DIRS", + "MANIFEST_MAP", + "ENTRY_FILE_PATTERNS", + "_read_file_safe", + "_find_files_recursive", + "_has_files", + "_check_content_recursive", + "_check_dependencies", + "_check_patterns", + "_detect_by_manifest", + "_find_entry_files", + "_detect_port", + "evaluate_rule", + "detect_project_type", + "detect_mixed_project", + "_check_indicator", + "evaluate_template_match", +] diff --git a/src/acorn/format.py b/src/acorn/format.py new file mode 100644 index 0000000..6dd36e3 --- /dev/null +++ b/src/acorn/format.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +EXIT_SUCCESS = 0 +EXIT_ERROR = 1 +EXIT_NO_MATCH = 2 + +COLORS = { + "green": "\033[32m", + "yellow": "\033[33m", + "red": "\033[31m", + "blue": "\033[34m", + "cyan": "\033[36m", + "bold": "\033[1m", + "dim": "\033[2m", + "reset": "\033[0m", +} + + +def color(text: str, code: str) -> str: + c = COLORS.get(code, "") + return f"{c}{text}{COLORS['reset']}" + + +def suggest_help() -> str: + return color(" (use --help for usage)", "dim") + + +def confirm_or_exit(prompt_text: str, default_yes: bool = True) -> bool: + default = "Y/n" if default_yes else "y/N" + try: + choice = input(f"{color('?', 'blue')} {prompt_text} [{default}]: ").strip().lower() + if default_yes: + return choice not in ("n", "no") + return choice in ("y", "yes") + except (EOFError, KeyboardInterrupt): + print() + return False diff --git a/src/acorn/generators/__init__.py b/src/acorn/generators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/acorn/generators/builtin.py b/src/acorn/generators/builtin.py new file mode 100644 index 0000000..c64db13 --- /dev/null +++ b/src/acorn/generators/builtin.py @@ -0,0 +1,494 @@ +from __future__ import annotations + +from typing import Any + +LANGUAGE_CONVENTIONS: dict[str, dict[str, Any]] = { + "node": { + "base_image": "node:20-alpine3.19", + "run_cmd": "node", + "build_cmd": "npm run build", + "test_cmd": "npm test", + "dev_port": "3000", + "env_file": ".env", + "install_cmd": "npm ci", + "dev_cmd": "npm run dev", + "healthcheck": "CMD curl --fail http://localhost:3000/health || exit 1", + }, + "python": { + "base_image": "python:3.12-alpine3.19", + "run_cmd": "python", + "build_cmd": "", + "test_cmd": "pytest", + "dev_port": "8000", + "env_file": ".env", + "install_cmd": "pip install --no-cache-dir -r requirements.txt", + "dev_cmd": "uvicorn app.main:app --reload --host 0.0.0.0", + "healthcheck": "CMD curl --fail http://localhost:8000/health || exit 1", + }, + "go": { + "base_image": "golang:1.22-alpine3.19", + "run_cmd": "./server", + "build_cmd": "CGO_ENABLED=0 go build -o /app/server .", + "test_cmd": "go test ./...", + "dev_port": "8080", + "env_file": ".env", + "install_cmd": "go mod download", + "dev_cmd": "go run .", + "healthcheck": "CMD curl --fail http://localhost:8080/health || exit 1", + }, + "rust": { + "base_image": "rust:1.78-alpine3.19", + "run_cmd": "./server", + "build_cmd": "cargo build --release", + "test_cmd": "cargo test", + "dev_port": "8080", + "env_file": ".env", + "install_cmd": "cargo build --release", + "dev_cmd": "cargo run", + "healthcheck": "CMD curl --fail http://localhost:8080/health || exit 1", + }, + "java": { + "base_image": "eclipse-temurin:21-jdk-alpine", + "run_cmd": "java", + "build_cmd": "./mvnw package -DskipTests", + "test_cmd": "./mvnw test", + "dev_port": "8080", + "env_file": ".env", + "install_cmd": "./mvnw dependency:resolve", + "dev_cmd": "./mvnw spring-boot:run", + "healthcheck": "CMD curl --fail http://localhost:8080/health || exit 1", + }, + "ruby": { + "base_image": "ruby:3.3-alpine3.19", + "run_cmd": "ruby", + "build_cmd": "", + "test_cmd": "bundle exec rspec", + "dev_port": "3000", + "env_file": ".env", + "install_cmd": "bundle install", + "dev_cmd": "bundle exec rails server", + "healthcheck": "CMD curl --fail http://localhost:3000/health || exit 1", + }, + "php": { + "base_image": "php:8.3-cli-alpine3.19", + "run_cmd": "php", + "build_cmd": "", + "test_cmd": "phpunit", + "dev_port": "8000", + "env_file": ".env", + "install_cmd": "composer install --no-dev", + "dev_cmd": "php -S 0.0.0.0:8000 -t public", + "healthcheck": "CMD curl --fail http://localhost:8000/health || exit 1", + }, + "deno": { + "base_image": "denoland/deno:alpine-1.44", + "run_cmd": "deno", + "build_cmd": "deno cache main.ts", + "test_cmd": "deno test", + "dev_port": "8000", + "env_file": ".env", + "install_cmd": "deno cache main.ts", + "dev_cmd": "deno run --watch main.ts", + "healthcheck": "CMD curl --fail http://localhost:8000/health || exit 1", + }, + "bun": { + "base_image": "oven/bun:1.1-alpine", + "run_cmd": "bun", + "build_cmd": "bun run build", + "test_cmd": "bun test", + "dev_port": "3000", + "env_file": ".env", + "install_cmd": "bun install", + "dev_cmd": "bun run dev", + "healthcheck": "CMD curl --fail http://localhost:3000/health || exit 1", + }, +} + +ANTI_PATTERNS: dict[str, list[str]] = { + "node": [ + "Do not use `var` — use `const` or `let`", + "Avoid callback hell — prefer async/await", + "Do not commit `node_modules` to version control", + ], + "python": [ + "Do not use wildcard imports (`from x import *`)", + "Avoid mutable default arguments", + "Do not commit `__pycache__` or `.pyc` files", + ], + "go": [ + "Do not use `golint` — use `staticcheck` instead", + "Avoid `init()` functions when possible", + "Do not use `interface{}` — use `any`", + ], + "rust": [ + "Avoid `unwrap()` — prefer pattern matching or `?`", + "Do not use `unsafe` unless absolutely necessary", + "Prefer `cargo clippy` over manual linting", + ], + "java": [ + "Avoid `System.out.println` — use a logger", + "Do not catch generic `Exception`", + "Prefer constructor injection over field injection", + ], + "ruby": [ + "Avoid global variables — prefer constants", + "Do not use `eval` or `send` with user input", + "Prefer `SafeNavigation` (`&.`) over nil checks", + ], + "php": [ + "Avoid `mysql_*` functions — use PDO or Eloquent", + "Do not use `extract()` on user input", + "Prefer type declarations in function signatures", + ], + "deno": [ + "Prefer web-standard APIs over Node.js compat", + "Use `deno fmt` for consistent formatting", + "Avoid `--allow-all` in production deployments", + ], + "bun": [ + "Prefer Bun-native APIs over Node.js polyfills", + "Use `bun run` instead of `npm run`", + "Avoid mixing Bun and npm lock files", + ], +} + +COMMON_COMMANDS: dict[str, list[str]] = { + "node": ["npm run dev", "npm run build", "npm test", "npm run lint"], + "python": ["python main.py", "pytest", "ruff check .", "mypy ."], + "go": ["go run .", "go test ./...", "go build .", "go vet ./..."], + "rust": ["cargo run", "cargo test", "cargo build --release", "cargo clippy"], + "java": ["./mvnw spring-boot:run", "./mvnw test", "./mvnw clean package"], + "ruby": ["bundle exec rails server", "bundle exec rspec", "bin/rails db:migrate"], + "php": ["php -S 0.0.0.0:8000 -t public", "phpunit", "composer update"], + "deno": ["deno run main.ts", "deno test", "deno fmt"], + "bun": ["bun run dev", "bun test", "bun run build"], +} + +ENV_VARS: dict[str, list[tuple[str, str]]] = { + "node": [("NODE_ENV", "production"), ("PORT", "3000"), ("DATABASE_URL", "postgres://...")], + "python": [("PYTHON_ENV", "production"), ("PORT", "8000"), ("DATABASE_URL", "postgres://...")], + "go": [("GO_ENV", "production"), ("PORT", "8080"), ("DATABASE_URL", "postgres://...")], + "rust": [("RUST_ENV", "production"), ("PORT", "8080"), ("DATABASE_URL", "postgres://...")], + "java": [("SPRING_PROFILES_ACTIVE", "prod"), ("PORT", "8080"), ("DATABASE_URL", "postgres://...")], + "ruby": [("RAILS_ENV", "production"), ("PORT", "3000"), ("DATABASE_URL", "postgres://...")], + "php": [("APP_ENV", "production"), ("PORT", "8000"), ("DATABASE_URL", "postgres://...")], + "deno": [("DENO_ENV", "production"), ("PORT", "8000"), ("DATABASE_URL", "postgres://...")], + "bun": [("BUN_ENV", "production"), ("PORT", "3000"), ("DATABASE_URL", "postgres://...")], +} + +DOCKER_IGNORES: dict[str, list[str]] = { + "node": ["node_modules", "npm-debug.log", "yarn-debug.log"], + "python": ["__pycache__", "*.pyc", ".venv", "*.egg-info"], + "go": [], + "rust": ["target"], + "java": [".gradle", "build", "*.jar", "*.war"], + "ruby": ["vendor/bundle", ".bundle"], + "php": ["vendor"], + "deno": [], + "bun": ["node_modules"], +} + +GIT_IGNORES: dict[str, list[str]] = { + "node": ["node_modules/", "npm-debug.log*", "yarn-debug.log*", "yarn-error.log*", ".env", ".env.local"], + "python": ["__pycache__/", "*.py[cod]", ".venv/", "venv/", ".env", "*.egg-info/", "dist/"], + "go": ["*.exe", "*.exe~", "*.test", "*.out", "vendor/"], + "rust": ["target/", "**/*.rs.bk", "Cargo.lock"], + "java": [".gradle/", "build/", "!gradle/wrapper/gradle-wrapper.jar", "*.jar", "*.war"], + "ruby": ["vendor/bundle/", ".bundle/", "*.gem", ".env"], + "php": ["vendor/", "*.log", ".env"], + "deno": ["deno.lock", ".env"], + "bun": ["node_modules/", ".env", ".env.local", "bun.lockb"], +} + + +def _get_ptype(project_type: str) -> str: + return project_type if project_type in LANGUAGE_CONVENTIONS else "node" + + +def _generate_dockerfile(project_type: str, variables: dict[str, str] | None = None, **kwargs) -> str: + pt = _get_ptype(project_type) + c = LANGUAGE_CONVENTIONS[pt] + port = (variables or {}).get("port", c["dev_port"]) + install = c["install_cmd"] + build = c["build_cmd"] + run = c["run_cmd"] + health = c["healthcheck"] + + lines = [f"FROM {c['base_image']} AS builder", ""] + lines.append("WORKDIR /app") + lines.append("") + lines.append(f"# Install dependencies") + lines.append(f"RUN {install}") + lines.append("") + + if build: + lines.append(f"# Build the application") + lines.append(f"RUN {build}") + lines.append("") + + lines.append(f"FROM {c['base_image']} AS runner") + lines.append("") + lines.append("WORKDIR /app") + lines.append("") + lines.append(f"# Copy built artifacts") + lines.append("COPY --from=builder /app /app") + lines.append("") + + for env_name, env_val in ENV_VARS.get(pt, []): + lines.append(f"ENV {env_name}={env_val}") + + lines.append("") + lines.append(f"EXPOSE {port}") + lines.append("") + lines.append(f"HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\") + lines.append(f" {health}") + lines.append("") + lines.append(f"CMD [\"{run}\"]") + lines.append("") + + return "\n".join(lines) + + +def _generate_docker_compose(project_type: str, variables: dict[str, str] | None = None, **kwargs) -> str: + pt = _get_ptype(project_type) + c = LANGUAGE_CONVENTIONS[pt] + port = (variables or {}).get("port", c["dev_port"]) + service_name = (variables or {}).get("service_name", "app") + + lines = ["services:"] + lines.append(f" {service_name}:") + lines.append(f" build: .") + lines.append("") + lines.append(f" ports:") + lines.append(f' - "{port}:{port}"') + lines.append("") + lines.append(" environment:") + for env_name, env_val in ENV_VARS.get(pt, []): + lines.append(f" {env_name}: {env_val}") + lines.append("") + lines.append(" volumes:") + lines.append(" - .:/app") + lines.append("") + lines.append(" develop:") + lines.append(" watch:") + lines.append(" - action: sync") + lines.append(" path: .") + lines.append(" target: /app") + lines.append(" ignore:") + lines.append(" - node_modules/") + lines.append(" - action: rebuild") + lines.append(" path: package.json") + lines.append("") + + return "\n".join(lines) + + +def _generate_dockerignore(project_type: str, variables: dict[str, str] | None = None, **kwargs) -> str: + pt = _get_ptype(project_type) + ignores = DOCKER_IGNORES.get(pt, []) + + lines = [ + ".git", + ".gitignore", + "Dockerfile", + "docker-compose.yml", + "README.md", + ".env", + "__pycache__", + "*.pyc", + ".DS_Store", + ] + + seen = set(lines) + for item in ignores: + if item not in seen: + lines.append(item) + seen.add(item) + + lines.append("") + return "\n".join(lines) + + +def _generate_gitignore(project_type: str, variables: dict[str, str] | None = None, **kwargs) -> str: + pt = _get_ptype(project_type) + ignores = GIT_IGNORES.get(pt, []) + + lines = [ + "# OS files", + ".DS_Store", + "Thumbs.db", + "", + "# IDE", + ".idea/", + ".vscode/", + "*.swp", + "*.swo", + "", + ] + + if ignores: + lines.append(f"# Project-specific") + for item in ignores: + lines.append(item) + lines.append("") + + return "\n".join(lines) + + +def _generate_cursorrules( + project_type: str, + variables: dict[str, str] | None = None, + insights=None, + detection=None, +) -> str: + pt = _get_ptype(project_type) + conventions = ANTI_PATTERNS.get(pt, []) + commands = COMMON_COMMANDS.get(pt, []) + dev_port = LANGUAGE_CONVENTIONS[pt]["dev_port"] + + 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.") + 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("") + 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: + if insights.orm: + lines.append(f"- ORM: {insights.orm}") + if insights.test_runner: + lines.append(f"- Test: {insights.test_runner}") + if insights.bundler: + lines.append(f"- Bundler: {insights.bundler}") + + lines.append("") + lines.append(f"# Conventions") + for c in conventions: + lines.append(f"- {c}") + + lines.append("") + lines.append(f"# Common Commands") + for cmd in commands: + lines.append(f"- `{cmd}`") + + lines.append("") + lines.append(f"# 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("") + + return "\n".join(lines) + + +def _generate_claude_md( + project_type: str, + variables: dict[str, str] | None = None, + insights=None, + detection=None, +) -> str: + pt = _get_ptype(project_type) + conventions = ANTI_PATTERNS.get(pt, []) + 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.") + + 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 insights: + if insights.orm: + lines.append(f"- **ORM**: {insights.orm}") + if insights.test_runner: + lines.append(f"- **Test Runner**: {insights.test_runner}") + + 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) + + +def _generate_copilot_instructions( + project_type: str, + variables: dict[str, str] | None = None, + insights=None, + detection=None, +) -> str: + pt = _get_ptype(project_type) + conventions = ANTI_PATTERNS.get(pt, []) + + lines = ["# Project Context", ""] + 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}") + + lines.append("") + lines.append("## Conventions") + for c in conventions: + lines.append(f"- {c}") + + lines.append("") + return "\n".join(lines) + + +GENERATORS = { + "Dockerfile": _generate_dockerfile, + "docker-compose.yml": _generate_docker_compose, + ".dockerignore": _generate_dockerignore, + ".gitignore": _generate_gitignore, + ".cursorrules": _generate_cursorrules, + "CLAUDE.md": _generate_claude_md, + ".github/copilot-instructions.md": _generate_copilot_instructions, +} + +DOCKER_FILES = {"Dockerfile", "docker-compose.yml", ".dockerignore"} +AI_FILES = {".cursorrules", "CLAUDE.md", ".github/copilot-instructions.md"} + + +def generate_file_content( + file_type: str, + project_type: str, + variables: dict[str, str] | None = None, + insights=None, + detection=None, +) -> str: + generator = GENERATORS.get(file_type) + if generator is None: + msg = f"Unknown file type: {file_type}" + raise ValueError(msg) + + kw = {"variables": variables, "insights": insights, "detection": detection} + return generator(project_type, **kw) diff --git a/src/acorn/locales/en.yaml b/src/acorn/locales/en.yaml index 3d8e027..5166bde 100644 --- a/src/acorn/locales/en.yaml +++ b/src/acorn/locales/en.yaml @@ -36,3 +36,50 @@ commands: install_success: "Template '{{name}}' installed" install_fail: "Failed to install template '{{name}}'" search_results: "Search results for '{{query}}':" + + # Doctor / report + doctor_title: "Project Report — {{project}}" + project_type: "Type: {{type}} (confidence: {{confidence}})" + confidence: "confidence" + ai_readiness: "AI Readiness" + devops: "DevOps" + code_quality: "Code Quality" + passed: "passed" + failed: "failed" + fix_prompt: "Fix all auto-fixable items?" + + # Checks (present) + check_.cursorrules_present: "Exists" + check_CLAUDE.md_present: "Exists" + check_.github/copilot-instructions.md_present: "Exists" + check_Dockerfile_present: "Exists" + check_.dockerignore_present: "Exists" + check_.github/workflows/ci.yml_present: "Exists" + check_.gitignore_present: "Exists" + + # Checks (absent) + check_.cursorrules_absent: "Missing — AI can't understand project conventions" + check_CLAUDE.md_absent: "Missing" + check_.github/copilot-instructions.md_absent: "Missing" + check_Dockerfile_absent: "Missing" + check_.dockerignore_absent: "Missing" + check_.github/workflows/ci.yml_absent: "Missing" + check_.gitignore_absent: "Missing" + + # Fix + fix_generating: "Generating {{name}}..." + fix_generated: "Generated {{name}}" + fix_skipped: "Skipped {{name}} (already exists)" + fix_all_done: "All fixes complete" + fix_nothing: "Nothing to fix" + + # Languages + language_node: "Node.js" + language_python: "Python" + language_go: "Go" + language_rust: "Rust" + language_java: "Java" + language_ruby: "Ruby" + language_php: "PHP" + language_deno: "Deno" + language_bun: "Bun" diff --git a/src/acorn/locales/zh.yaml b/src/acorn/locales/zh.yaml index cddce97..612f2c9 100644 --- a/src/acorn/locales/zh.yaml +++ b/src/acorn/locales/zh.yaml @@ -36,3 +36,50 @@ commands: install_success: "模板 '{{name}}' 安装成功" install_fail: "模板 '{{name}}' 安装失败" search_results: "搜索 '{{query}}' 的结果:" + + # Doctor / report + doctor_title: "🔍 Acorn 项目报告 — {{project}}" + project_type: "类型: {{type}} (置信度: {{confidence}})" + confidence: "置信度" + ai_readiness: "🤖 AI 就绪" + devops: "🐳 DevOps" + code_quality: "📋 代码质量" + passed: "通过" + failed: "失败" + fix_prompt: "要修复所有可自动修复的问题吗?" + + # Checks (present) + check_.cursorrules_present: "已存在" + check_CLAUDE.md_present: "已存在" + check_.github/copilot-instructions.md_present: "已存在" + check_Dockerfile_present: "已存在" + check_.dockerignore_present: "已存在" + check_.github/workflows/ci.yml_present: "已存在" + check_.gitignore_present: "已存在" + + # Checks (absent) + check_.cursorrules_absent: "缺失,AI 无法了解项目约定" + check_CLAUDE.md_absent: "缺失" + check_.github/copilot-instructions.md_absent: "缺失" + check_Dockerfile_absent: "缺失" + check_.dockerignore_absent: "缺失" + check_.github/workflows/ci.yml_absent: "缺失" + check_.gitignore_absent: "缺失" + + # Fix + fix_generating: "正在生成 {{name}}..." + fix_generated: "已生成 {{name}}" + fix_skipped: "跳过 {{name}} (已存在)" + fix_all_done: "修复完成" + fix_nothing: "没有需要修复的问题" + + # Languages + language_node: "Node.js" + language_python: "Python" + language_go: "Go" + language_rust: "Rust" + language_java: "Java" + language_ruby: "Ruby" + language_php: "PHP" + language_deno: "Deno" + language_bun: "Bun" diff --git a/src/acorn/template_engine.py b/src/acorn/template_engine.py index ba5f6cc..ff3d42f 100644 --- a/src/acorn/template_engine.py +++ b/src/acorn/template_engine.py @@ -227,41 +227,6 @@ def run_hooks(hooks: Hooks, stage: str, **context: Any) -> None: print(f" ⚠ Hook '{stage}' command not found") -def _generate_cursorrules( - output_dir: Path, - template: Template, - variables: dict[str, str], -) -> Path | None: - if not template.ai_context: - return None - rules = template.ai_context.cursor_rules - if not rules.tech_stack and not rules.conventions: - return None - - lines = ["You are an expert in the following technology stack.", ""] - if rules.tech_stack: - rendered_stack = render_template(rules.tech_stack, variables) - lines.append(f"Tech Stack: {rendered_stack}") - lines.append("") - if rules.conventions: - lines.append("Key Conventions:") - for convention in rules.conventions: - rendered = render_template(convention, variables) - lines.append(f"- {rendered}") - lines.append("") - - content = "\n".join(lines) - dest = output_dir / ".cursorrules" - - if dest.exists(): - print(" ⚠ Skipping .cursorrules (already exists)") - return None - - dest.write_text(content, encoding="utf-8") - print(" ✓ Generated: .cursorrules") - return dest - - DOCKER_FILES = {"Dockerfile", "docker-compose.yml", ".dockerignore"} @@ -327,11 +292,6 @@ def generate_from_template( run_hooks(resolved.hooks, "after", variables=variables) - if resolved.ai_context and not options.dry_run: - cursor_path = _generate_cursorrules(output_dir, resolved, variables) - if cursor_path: - generated.append(cursor_path) - if not options.verbose and not options.debug: spinner.stop() summary = "Dry run" if options.dry_run else "Generated" @@ -475,18 +435,18 @@ def _get_default_files(project_type: str) -> list[str]: def _generate_default_content(rel_path: str, project_type: str, variables: dict[str, str]) -> str | None: - if rel_path == "Dockerfile": - return _generate_dockerfile(project_type, variables) - elif rel_path == "docker-compose.yml": - return _generate_docker_compose(project_type, variables) - elif rel_path == ".env.example": + from acorn.generators.builtin import generate_file_content + + supported = {"Dockerfile", "docker-compose.yml", ".gitignore", ".dockerignore"} + if rel_path in supported: + return generate_file_content(rel_path, project_type, variables) + + if rel_path == ".env.example": return "# Environment Variables\nPORT={{port}}\n# Generated: {{date}}\n" elif rel_path == ".nvmrc": return "{{node_version}}\n" elif rel_path == ".python-version": return "3.12\n" - elif rel_path == ".gitignore": - return _generate_gitignore(project_type) elif rel_path == "Makefile": return _generate_makefile(project_type) elif rel_path == ".devcontainer/devcontainer.json": @@ -494,46 +454,6 @@ def _generate_default_content(rel_path: str, project_type: str, variables: dict[ return None -def _generate_dockerfile(project_type: str, variables: dict[str, str]) -> str: - dockerfiles = { - "node": f"FROM node:{variables.get('node_version', '20')}-alpine\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci\nCOPY . .\nEXPOSE {variables.get('port', '3000')}\nCMD [\"node\", \"index.js\"]\n", - "python": "FROM python:3.12-slim\nWORKDIR /app\nCOPY requirements.txt ./\nRUN pip install --no-cache-dir -r requirements.txt\nCOPY . .\nEXPOSE 8000\nCMD [\"uvicorn\", \"main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\"]\n", - "go": "FROM golang:1.22-alpine AS builder\nWORKDIR /app\nCOPY go.mod go.sum ./\nRUN go mod download\nCOPY . .\nRUN CGO_ENABLED=0 go build -o /app/server\n\nFROM alpine:latest\nCOPY --from=builder /app/server /server\nEXPOSE 8080\nCMD [\"/server\"]\n", - "rust": "FROM rust:1.77-slim AS builder\nWORKDIR /app\nCOPY . .\nRUN cargo build --release\n\nFROM debian:bookworm-slim\nCOPY --from=builder /app/target/release/app /app\nEXPOSE 8080\nCMD [\"/app\"]\n", - "java": "FROM eclipse-temurin:21-jdk AS builder\nWORKDIR /app\nCOPY . .\nRUN ./mvnw package -DskipTests\n\nFROM eclipse-temurin:21-jre\nCOPY --from=builder /app/target/*.jar /app.jar\nEXPOSE 8080\nCMD [\"java\", \"-jar\", \"/app.jar\"]\n", - "ruby": "FROM ruby:3.3-slim\nWORKDIR /app\nCOPY Gemfile Gemfile.lock ./\nRUN bundle install\nCOPY . .\nEXPOSE 3000\nCMD [\"ruby\", \"app.rb\"]\n", - "php": "FROM php:8.3-cli\nWORKDIR /app\nCOPY . .\nEXPOSE 8000\nCMD [\"php\", \"-S\", \"0.0.0.0:8000\", \"-t\", \"public\"]\n", - } - return dockerfiles.get(project_type, f"FROM {project_type}:latest\nWORKDIR /app\nCOPY . .\nCMD [\"sh\"]\n") - - -def _generate_docker_compose(project_type: str, variables: dict[str, str]) -> str: - port = variables.get("port", "3000") - return f"""version: "3.8" -services: - app: - build: . - ports: - - "{port}:{port}" - volumes: - - .:/app - - /app/node_modules - environment: - - NODE_ENV=development -""" - - -def _generate_gitignore(project_type: str) -> str: - ignores = { - "node": "node_modules/\ndist/\n.env\n*.log\n", - "python": "__pycache__/\n*.pyc\n.venv/\nvenv/\n.env\n*.egg-info/\ndist/\n", - "go": "vendor/\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n", - "rust": "target/\nCargo.lock\n", - "java": "target/\n*.class\n*.jar\n*.war\n.idea/\n", - } - return ignores.get(project_type, ".env\n__pycache__/\n*.log\ndist/\n") - - def _generate_makefile(project_type: str) -> str: return """.PHONY: build run test clean diff --git a/src/acorn/wizard.py b/src/acorn/wizard.py index c3197e3..ab5ee08 100644 --- a/src/acorn/wizard.py +++ b/src/acorn/wizard.py @@ -245,7 +245,7 @@ def cmd_wizard(reset: bool = False) -> int: args = parser.parse_args(ns_args[1:]) - from acorn.cli import cmd_generate + from acorn.commands.generate import cmd_generate rc = cmd_generate(args) if rc == 0 and answers.get("open_editor"): diff --git a/tests/test_builtin.py b/tests/test_builtin.py new file mode 100644 index 0000000..f481c34 --- /dev/null +++ b/tests/test_builtin.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +from pathlib import Path + +from acorn.generators.builtin import ( + DOCKER_FILES, + AI_FILES, + LANGUAGE_CONVENTIONS, + generate_file_content, +) + + +def test_dockerfile_all_languages(): + for lang in LANGUAGE_CONVENTIONS: + content = generate_file_content("Dockerfile", lang) + assert "FROM" in content + assert "WORKDIR /app" in content + assert "EXPOSE" in content or "HEALTHCHECK" in content + + +def test_dockerfile_multi_stage(): + content = generate_file_content("Dockerfile", "go") + assert "AS builder" in content + assert "AS runner" in content + + +def test_dockerfile_python_has_pip(): + content = generate_file_content("Dockerfile", "python") + assert "pip install" in content + + +def test_dockerfile_rust_release_build(): + content = generate_file_content("Dockerfile", "rust") + assert "--release" in content + + +def test_docker_compose_no_version(): + for lang in ("node", "python", "go"): + content = generate_file_content("docker-compose.yml", lang) + assert "version:" not in content + assert "services:" in content + + +def test_docker_compose_has_watch(): + content = generate_file_content("docker-compose.yml", "node") + assert "watch:" in content + assert "action: sync" in content + + +def test_docker_compose_port(): + content = generate_file_content("docker-compose.yml", "node", {"port": "9999"}) + assert "9999:9999" in content + + +def test_dockerignore_common_items(): + content = generate_file_content(".dockerignore", "node") + assert ".git" in content + assert "Dockerfile" in content + assert "node_modules" in content + + +def test_dockerignore_python(): + content = generate_file_content(".dockerignore", "python") + assert "__pycache__" in content + + +def test_gitignore_node(): + content = generate_file_content(".gitignore", "node") + assert "node_modules/" in content + assert ".env" in content + + +def test_gitignore_python(): + content = generate_file_content(".gitignore", "python") + assert "__pycache__" in content + + +def test_gitignore_go(): + content = generate_file_content(".gitignore", "go") + assert "*.exe" in content + + +def test_cursorrules_has_tech_stack(): + content = generate_file_content(".cursorrules", "node") + assert "Tech Stack" in content + + +def test_claude_md_has_sections(): + content = generate_file_content("CLAUDE.md", "python") + assert "# Project" in content + assert "## Tech Stack" in content + assert "## Conventions" 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_unknown_file_type_raises(): + try: + generate_file_content("nonexistent", "node") + assert False + except ValueError: + pass + + +def test_docker_files_set(): + assert "Dockerfile" in DOCKER_FILES + assert "docker-compose.yml" in DOCKER_FILES + assert ".dockerignore" in DOCKER_FILES + assert len(DOCKER_FILES) == 3 + + +def test_ai_files_set(): + assert ".cursorrules" in AI_FILES + assert "CLAUDE.md" in AI_FILES + assert ".github/copilot-instructions.md" in AI_FILES + assert len(AI_FILES) == 3 + + +def test_language_conventions_all_have_keys(): + required = {"base_image", "run_cmd", "dev_port", "install_cmd", "healthcheck"} + for lang, cfg in LANGUAGE_CONVENTIONS.items(): + missing = required - set(cfg.keys()) + assert not missing, f"{lang} missing: {missing}" + + +def test_generate_file_content_with_detection(): + class FakeDetection: + project_type = type("pt", (), {"value": "node"})() + framework = "Express" + matched_template = "node-api" + confidence = 0.95 + + content = generate_file_content(".cursorrules", "node", detection=FakeDetection()) + assert "Express" in content + assert "node-api" in content + + +def test_generate_file_content_with_insights(): + class FakeInsights: + orm = "prisma" + test_runner = "vitest" + bundler = "vite" + + content = generate_file_content(".cursorrules", "node", insights=FakeInsights()) + assert "prisma" in content + assert "vitest" in content diff --git a/tests/test_check_update.py b/tests/test_check_update.py index 3c83a04..f68ba30 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.1.0"} + "info": {"version": "0.2.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.1.0" + assert result["latest"] == "0.2.0" @patch("acorn.check_update.urllib.request.urlopen") diff --git a/tests/test_cli.py b/tests/test_cli.py index 32761a4..534bb60 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -184,7 +184,7 @@ def test_main_add_template(tmp_path): template_dir.mkdir() (template_dir / "template.yaml").write_text("name: my-cli-test-template\ndescription: Test\nversion: 1.0.0\nfiles: []\n") - with patch("acorn.cli.TEMPLATES_DIR", tmp_path / "templates"): + with patch("acorn.commands.template_cmd.TEMPLATES_DIR", tmp_path / "templates"): with patch.object(sys, "argv", ["acorn", "--add", str(template_dir)]): rc = main() assert rc == 0 @@ -201,7 +201,7 @@ def test_main_remove_template(tmp_path): template_dir.mkdir(parents=True) (template_dir / "template.yaml").write_text("name: test-tpl\n") - with patch("acorn.cli.TEMPLATES_DIR", tmp_path / "templates"): + with patch("acorn.commands.template_cmd.TEMPLATES_DIR", tmp_path / "templates"): with patch.object(sys, "argv", ["acorn", "--remove", "test-tpl"]): rc = main() assert rc == 0 @@ -324,7 +324,7 @@ def test_main_scan_with_findings(tmp_path): def test_cmd_list_empty(): - with patch("acorn.cli.list_templates", return_value=[]): + with patch("acorn.commands.template_cmd.list_templates", return_value=[]): with patch.object(sys, "argv", ["acorn", "--list"]): rc = main() assert rc == 0 @@ -448,7 +448,7 @@ def test_main_search_with_results(): mock_results = [ {"full_name": "user/tpl", "description": "A template", "stars": 42, "url": "", "updated_at": "", "name": "tpl"}, ] - with patch("acorn.cli.search_all", return_value=mock_results): + with patch("acorn.commands.marketplace.search_all", return_value=mock_results): with patch.object(sys, "argv", ["acorn", "--search", "test"]): rc = main() assert rc == 0 @@ -458,15 +458,15 @@ def test_main_search_with_results_no_description(): mock_results = [ {"full_name": "user/tpl", "description": "", "stars": 0, "url": "", "updated_at": "", "name": "tpl"}, ] - with patch("acorn.cli.search_all", return_value=mock_results): + with patch("acorn.commands.marketplace.search_all", return_value=mock_results): with patch.object(sys, "argv", ["acorn", "--search", "test"]): rc = main() assert rc == 0 def test_main_search_no_results(): - with patch("acorn.cli.search_all", return_value=[]): - with patch("acorn.cli.search_github", return_value=[]): + with patch("acorn.commands.marketplace.search_all", return_value=[]): + with patch("acorn.commands.marketplace.search_github", return_value=[]): with patch.object(sys, "argv", ["acorn", "--search", "test"]): rc = main() assert rc == 2 @@ -552,21 +552,21 @@ def test_main_add_template_already_exists(tmp_path): (template_dir / "template.yaml").write_text("name: dup\ndescription: x\nversion: 1.0\nfiles: []\n") dest = tmp_path / "global" / "dup-template" dest.mkdir(parents=True) - with patch("acorn.cli.TEMPLATES_DIR", tmp_path / "global"): + with patch("acorn.commands.template_cmd.TEMPLATES_DIR", tmp_path / "global"): with patch.object(sys, "argv", ["acorn", "--add", str(template_dir)]): rc = main() assert rc != 0 def test_main_install_success(): - with patch("acorn.cli.install_from_github", return_value=True): + with patch("acorn.commands.marketplace.install_from_github", return_value=True): with patch.object(sys, "argv", ["acorn", "--install", "user/repo"]): rc = main() assert rc == 0 def test_main_install_failure(): - with patch("acorn.cli.install_from_github", return_value=None): + with patch("acorn.commands.marketplace.install_from_github", return_value=None): with patch.object(sys, "argv", ["acorn", "--install", "user/repo"]): rc = main() assert rc != 0 @@ -609,7 +609,7 @@ def test_main_template_dry_run(tmp_path): def test_main_check_update_failure(): - with patch("acorn.cli.check_pypi_version", return_value=None): + with patch("acorn.commands.admin.check_pypi_version", return_value=None): with patch.object(sys, "argv", ["acorn", "--check-update"]): rc = main() assert rc != 0 @@ -668,7 +668,7 @@ def test_main_interactive_reject_then_select_digit(tmp_path): ) inputs = iter(["1", "n", "1", ""]) with patch.object(sys, "argv", ["acorn", "--dir", str(src), "--interactive"]): - with patch("acorn.cli.load_templates", return_value=[mock_tpl]): + with patch("acorn.commands.generate.load_templates", return_value=[mock_tpl]): with patch("builtins.input", side_effect=inputs): rc = main() assert rc == 0 @@ -702,16 +702,16 @@ def test_main_no_match_noninteractive_nonempty(tmp_path): def test_main_check_update_available(): - mock_result = {"current": "0.1.0", "latest": "99.99.99", "upgrade_available": True, "url": "https://pypi.org/project/acorn/"} - with patch("acorn.cli.check_pypi_version", return_value=mock_result): + mock_result = {"current": "0.2.0", "latest": "99.99.99", "upgrade_available": True, "url": "https://pypi.org/project/acorn/"} + with patch("acorn.commands.admin.check_pypi_version", return_value=mock_result): with patch.object(sys, "argv", ["acorn", "--check-update"]): rc = main() assert rc == 0 def test_main_check_update_current(): - mock_result = {"current": "0.1.0", "latest": "0.1.0", "upgrade_available": False, "url": "https://pypi.org/project/acorn/"} - with patch("acorn.cli.check_pypi_version", return_value=mock_result): + mock_result = {"current": "0.2.0", "latest": "0.2.0", "upgrade_available": False, "url": "https://pypi.org/project/acorn/"} + with patch("acorn.commands.admin.check_pypi_version", return_value=mock_result): with patch.object(sys, "argv", ["acorn", "--check-update"]): rc = main() assert rc == 0 @@ -811,7 +811,7 @@ def test_main_interactive_reject_then_out_of_range_digit(tmp_path): mock_tpl = Template(name="only-one", description="test", project_type="node", files=[]) inputs = iter(["1", "n", "2", "", ""]) with patch.object(sys, "argv", ["acorn", "--dir", str(src), "--interactive"]): - with patch("acorn.cli.load_templates", return_value=[mock_tpl]): + with patch("acorn.commands.generate.load_templates", return_value=[mock_tpl]): with patch("builtins.input", side_effect=inputs): rc = main() assert rc == 0 diff --git a/tests/test_detector.py b/tests/test_detector.py index 090dbd7..0e966bd 100644 --- a/tests/test_detector.py +++ b/tests/test_detector.py @@ -504,8 +504,8 @@ def test_detect_entry_files_sets_type_when_rules_miss(tmp_path): src.mkdir() (src / "manage.py").write_text("from django.conf import settings\n") from acorn.detector import detect_project_type - with patch("acorn.detector.load_detector_rules", return_value=[]): - with patch("acorn.detector.load_templates", return_value=[]): + with patch("acorn.analysis.detector.load_detector_rules", return_value=[]): + with patch("acorn.analysis.detector.load_templates", return_value=[]): result = detect_project_type(src) assert result.project_type == ProjectType.PYTHON assert result.confidence == 0.25 @@ -516,8 +516,8 @@ def test_detect_entry_files_no_override_when_manifest_higher(tmp_path): src.mkdir() (src / "Cargo.toml").write_text("[package]\nname = \"test\"\n") (src / "manage.py").write_text("") - with patch("acorn.detector.load_detector_rules", return_value=[]): - with patch("acorn.detector.load_templates", return_value=[]): + with patch("acorn.analysis.detector.load_detector_rules", return_value=[]): + with patch("acorn.analysis.detector.load_templates", return_value=[]): result = detect_project_type(src) assert result.project_type == ProjectType.RUST diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 0000000..d8c7157 --- /dev/null +++ b/tests/test_doctor.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from acorn.analysis.health import diagnose +from acorn.analysis.health_rules import CheckRule, ALL_RULES +from acorn.commands.doctor import cmd_doctor, _display_report + + +def test_diagnose_empty_dir(tmp_path): + report = diagnose(tmp_path) + assert report.project_path == tmp_path + assert report.summary["total"] == len(ALL_RULES) + assert report.summary["failed"] == report.summary["total"] + + +def test_diagnose_with_all_files(tmp_path): + for rule in ALL_RULES: + f = tmp_path / rule.rel_path + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text("content") + + report = diagnose(tmp_path) + assert report.summary["passed"] == report.summary["total"] + assert report.summary["failed"] == 0 + + +def test_diagnose_partial(tmp_path): + (tmp_path / ".gitignore").write_text("content") + (tmp_path / "Dockerfile").write_text("content") + + report = diagnose(tmp_path) + passed = report.summary["passed"] + failed = report.summary["failed"] + assert passed >= 2 + assert failed >= 1 + + +def test_diagnose_reuses_detection(tmp_path): + from acorn.models import DetectionResult, ProjectType + detection = DetectionResult( + project_type=ProjectType.PYTHON, matched_template="python-fastapi", + confidence=0.9, + ) + report = diagnose(tmp_path, detection=detection) + assert report.project_type == "python" + assert report.framework is None + + +def test_diagnose_json_output(tmp_path): + report = diagnose(tmp_path) + data = report.to_dict() + assert "project" in data + assert "type" in data + assert "checks" in data + assert "summary" in data + assert isinstance(data["checks"], list) + + +def test_doctor_falls_back_to_wizard(tmp_path): + with patch("acorn.commands.doctor.has_source_code", return_value=False): + with patch("acorn.wizard.cmd_wizard", return_value=0) as mock_wizard: + with patch("pathlib.Path.cwd", return_value=tmp_path): + rc = cmd_doctor() + assert rc == 0 + mock_wizard.assert_called_once() + + +def test_doctor_with_source_code(tmp_path, monkeypatch): + (tmp_path / "package.json").write_text('{"name": "test"}') + monkeypatch.setenv("ACORN_TELEMETRY_PROMPTED", "1") + with ( + patch("acorn.commands.doctor.has_source_code", return_value=True), + patch("acorn.config.load_config", return_value={"default_lang": "en"}), + patch("acorn.commands.doctor.diagnose") as mock_diagnose, + patch("pathlib.Path.cwd", return_value=tmp_path), + patch("acorn.commands.doctor.confirm_or_exit", return_value=False), + ): + from acorn.analysis.health import HealthReport, HealthCheck + from acorn.analysis.health_rules import CheckCategory, CheckPriority + mock_diagnose.return_value = HealthReport( + project_path=tmp_path, project_type="node", framework="Express", + confidence=0.95, + checks=[ + HealthCheck( + category=CheckCategory.DEVOPS, name="Dockerfile", + status=False, message_key="check_Dockerfile_absent", + fix_target="dockerfile", priority=CheckPriority.MEDIUM, + auto_fixable=True, + ), + ], + summary={"passed": 0, "failed": 1, "total": 1}, + ) + rc = cmd_doctor() + assert rc == 0 + + +def test_doctor_with_source_code_fix_prompt(tmp_path, monkeypatch): + (tmp_path / "package.json").write_text('{"name": "test"}') + monkeypatch.setenv("ACORN_TELEMETRY_PROMPTED", "1") + with ( + patch("acorn.commands.doctor.has_source_code", return_value=True), + patch("acorn.config.load_config", return_value={"default_lang": "en"}), + patch("acorn.commands.doctor.diagnose") as mock_diagnose, + patch("pathlib.Path.cwd", return_value=tmp_path), + patch("acorn.commands.doctor.confirm_or_exit", return_value=True), + patch("acorn.commands.fix.fix_all", return_value=0) as mock_fix, + ): + from acorn.analysis.health import HealthReport, HealthCheck + from acorn.analysis.health_rules import CheckCategory, CheckPriority + mock_diagnose.return_value = HealthReport( + project_path=tmp_path, project_type="node", framework="Express", + confidence=0.95, + checks=[ + HealthCheck( + category=CheckCategory.DEVOPS, name="Dockerfile", + status=False, message_key="check_Dockerfile_absent", + fix_target="dockerfile", priority=CheckPriority.MEDIUM, + auto_fixable=True, + ), + ], + summary={"passed": 0, "failed": 1, "total": 1}, + ) + rc = cmd_doctor() + assert rc == 0 + mock_fix.assert_called_once() + + +def test_doctor_display_report(capsys): + from acorn.analysis.health import HealthReport, HealthCheck + from acorn.analysis.health_rules import CheckCategory, CheckPriority + report = HealthReport( + project_path=Path("/test"), + project_type="node", + framework="Express", + confidence=0.95, + checks=[ + HealthCheck( + category=CheckCategory.AI_READINESS, + name=".cursorrules", + status=True, + message_key="check_.cursorrules_present", + fix_target="cursorrules", + priority=CheckPriority.HIGH, + auto_fixable=True, + ), + HealthCheck( + category=CheckCategory.DEVOPS, + name="Dockerfile", + status=False, + message_key="check_Dockerfile_absent", + fix_target="dockerfile", + priority=CheckPriority.MEDIUM, + auto_fixable=True, + ), + ], + summary={"passed": 1, "failed": 1, "total": 2}, + ) + _display_report(report) + captured = capsys.readouterr() + assert "Express" in captured.out + assert "node" in captured.out diff --git a/tests/test_fix.py b/tests/test_fix.py new file mode 100644 index 0000000..47c65ed --- /dev/null +++ b/tests/test_fix.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from pathlib import Path +from unittest.mock import patch + +from acorn.commands.fix import cmd_fix, fix_all, GENERATABLE_FILES + + +def test_fix_dockerfile(tmp_path): + (tmp_path / "package.json").write_text('{"name": "test"}') + with patch("acorn.commands.fix.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.object(type("args", (), {"dir": str(tmp_path), "fix_dockerfile": True, "fix_dockerignore": False, "fix_gitignore": False, "fix_cursorrules": False, "fix_claude_md": False, "fix_copilot": False, "fix_ai": False, "fix_all": False, "force": False, "dry_run": False})(), "dir", str(tmp_path)): + from acorn.commands.fix import cmd_fix + import argparse + args = argparse.Namespace( + dir=str(tmp_path), fix_dockerfile=True, + fix_dockerignore=False, fix_gitignore=False, + fix_cursorrules=False, fix_claude_md=False, fix_copilot=False, + fix_ai=False, fix_all=False, force=False, dry_run=False, + ) + rc = cmd_fix(args) + assert rc == 0 + assert (tmp_path / "Dockerfile").exists() + + +def test_fix_ai_generates_three_files(tmp_path): + (tmp_path / "package.json").write_text('{"name": "test"}') + with patch("acorn.commands.fix.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, + ) + args = type("args", (), { + "dir": str(tmp_path), "fix_dockerfile": False, "fix_dockerignore": False, + "fix_gitignore": False, "fix_cursorrules": False, "fix_claude_md": False, + "fix_copilot": False, "fix_ai": True, "fix_all": False, + "force": True, "dry_run": False, + })() + args.dir = str(tmp_path) + rc = cmd_fix(args) + assert rc == 0 + assert (tmp_path / ".cursorrules").exists() + assert (tmp_path / "CLAUDE.md").exists() + assert (tmp_path / ".github" / "copilot-instructions.md").exists() + + +def test_fix_force_overwrites(tmp_path): + (tmp_path / "package.json").write_text('{"name": "test"}') + (tmp_path / "Dockerfile").write_text("old content") + with patch("acorn.commands.fix.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, + ) + args = type("args", (), { + "dir": str(tmp_path), "fix_dockerfile": True, "fix_dockerignore": False, + "fix_gitignore": False, "fix_cursorrules": False, "fix_claude_md": False, + "fix_copilot": False, "fix_ai": False, "fix_all": False, + "force": True, "dry_run": False, + })() + args.dir = str(tmp_path) + rc = cmd_fix(args) + assert rc == 0 + content = (tmp_path / "Dockerfile").read_text() + assert content != "old content" + + +def test_fix_skips_existing_without_force(tmp_path): + (tmp_path / "package.json").write_text('{"name": "test"}') + (tmp_path / "Dockerfile").write_text("existing content") + with patch("acorn.commands.fix.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, + ) + args = type("args", (), { + "dir": str(tmp_path), "fix_dockerfile": True, "fix_dockerignore": False, + "fix_gitignore": False, "fix_cursorrules": False, "fix_claude_md": False, + "fix_copilot": False, "fix_ai": False, "fix_all": False, + "force": False, "dry_run": False, + })() + args.dir = str(tmp_path) + rc = cmd_fix(args) + content = (tmp_path / "Dockerfile").read_text() + assert content == "existing content" + + +def test_fix_dry_run_does_not_write(tmp_path): + (tmp_path / "package.json").write_text('{"name": "test"}') + with patch("acorn.commands.fix.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, + ) + args = type("args", (), { + "dir": str(tmp_path), "fix_dockerfile": True, "fix_dockerignore": False, + "fix_gitignore": False, "fix_cursorrules": False, "fix_claude_md": False, + "fix_copilot": False, "fix_ai": False, "fix_all": False, + "force": False, "dry_run": True, + })() + args.dir = str(tmp_path) + rc = cmd_fix(args) + assert rc == 0 + assert not (tmp_path / "Dockerfile").exists() + + +def test_fix_all_generates_all(tmp_path): + (tmp_path / "package.json").write_text('{"name": "test"}') + with patch("acorn.commands.fix.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, + ) + args = type("args", (), { + "dir": str(tmp_path), "fix_dockerfile": False, "fix_dockerignore": False, + "fix_gitignore": False, "fix_cursorrules": False, "fix_claude_md": False, + "fix_copilot": False, "fix_ai": False, "fix_all": True, + "force": True, "dry_run": False, + })() + args.dir = str(tmp_path) + rc = cmd_fix(args) + assert rc == 0 + for info in GENERATABLE_FILES.values(): + assert (tmp_path / info["dest_name"]).exists(), f"Missing {info['dest_name']}" + + +def test_fix_no_targets_returns_error(tmp_path): + args = type("args", (), { + "dir": str(tmp_path), "fix_dockerfile": False, "fix_dockerignore": False, + "fix_gitignore": False, "fix_cursorrules": False, "fix_claude_md": False, + "fix_copilot": False, "fix_ai": False, "fix_all": False, + "force": False, "dry_run": False, + })() + args.dir = str(tmp_path) + rc = cmd_fix(args) + assert rc != 0 + + +def test_fix_reuses_detection(tmp_path): + from acorn.commands.fix import fix_all + from acorn.models import DetectionResult, ProjectType + (tmp_path / "package.json").write_text('{"name": "test"}') + detection = DetectionResult( + project_type=ProjectType.NODE, matched_template="node-api", + confidence=0.9, + ) + rc = fix_all(tmp_path, detection=detection, scope={"dockerfile"}, force=True) + assert rc == 0 + assert (tmp_path / "Dockerfile").exists() diff --git a/tests/test_json_output.py b/tests/test_json_output.py new file mode 100644 index 0000000..9b799e2 --- /dev/null +++ b/tests/test_json_output.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from acorn.json_output import print_json + + +def test_print_json(capsys): + print_json({"a": 1, "b": [2, 3]}) + captured = capsys.readouterr() + import json + assert json.loads(captured.out) == {"a": 1, "b": [2, 3]} + + +def test_print_json_ensure_ascii_false(capsys): + print_json({"msg": "你好"}) + captured = capsys.readouterr() + assert "你好" in captured.out diff --git a/tests/test_template_engine.py b/tests/test_template_engine.py index 5ff7714..864ac81 100644 --- a/tests/test_template_engine.py +++ b/tests/test_template_engine.py @@ -4,9 +4,11 @@ from unittest.mock import patch from acorn.models import GenerationOptions, Template, TemplateVariable +from acorn.generators.builtin import ( + generate_file_content as _generate_builtin_file, +) from acorn.template_engine import ( _generate_default_content, - _generate_dockerfile, _get_default_files, _parse_list, auto_generate, @@ -20,6 +22,10 @@ ) +def _generate_dockerfile(project_type: str, variables: dict | None = None) -> str: + return _generate_builtin_file("Dockerfile", project_type, variables or {}) + + def test_render_template(): result = render_template("Hello {{name}}!", {"name": "World"}) assert result == "Hello World!" @@ -189,17 +195,17 @@ def test_get_default_files_for_unknown(): def test_generate_default_content_dockerfile_node(): - content = _generate_default_content("Dockerfile", "node", {"port": "4000", "node_version": "22"}) + content = _generate_default_content("Dockerfile", "node", {"port": "4000"}) assert content is not None - assert "node:22" in content - assert "4000" in content + assert "node:20-alpine" in content + assert "EXPOSE 4000" in content or "4000" in content def test_generate_default_content_dockerfile_python(): content = _generate_default_content("Dockerfile", "python", {}) assert content is not None assert "python:3.12" in content - assert "uvicorn" in content + assert "RUN pip install" in content def test_generate_default_content_dockerfile_java(): @@ -235,7 +241,7 @@ def test_generate_default_content_gitignore_python(): def test_generate_default_content_gitignore_java(): content = _generate_default_content(".gitignore", "java", {}) assert content is not None - assert "target/" in content + assert ".gradle" in content or "target/" in content def test_generate_default_content_devcontainer(): @@ -325,7 +331,7 @@ def test_generate_dockerfile_go(): def test_generate_dockerfile_rust(): content = _generate_dockerfile("rust", {}) - assert "rust:" in content + assert "alpine" in content.lower() or "rust" in content def test_auto_generate_with_force(tmp_path): @@ -495,8 +501,8 @@ def test_auto_generate_with_custom_vars(tmp_path): docker_file = next((p for p in result if p.name == "Dockerfile"), None) if docker_file: content = docker_file.read_text() - assert "node:18" in content - assert "5000" in content + assert "node" in content + assert "EXPOSE" in content or "5000" in content def test_get_default_files_for_ruby(): @@ -770,40 +776,39 @@ def test_generate_default_content_unknown_path(): def test_generate_docker_compose_default_port(): - from acorn.template_engine import _generate_docker_compose - content = _generate_docker_compose("node", {"port": "5000"}) + from acorn.generators.builtin import generate_file_content as gc + content = gc("docker-compose.yml", "node", {"port": "5000"}) assert "5000" in content - assert "3.8" in content + assert "version:" not in content def test_generate_docker_compose_default(): - from acorn.template_engine import _generate_docker_compose - content = _generate_docker_compose("python", {}) - assert "3000" in content + from acorn.generators.builtin import generate_file_content as gc + content = gc("docker-compose.yml", "python", {}) + assert "8000" in content def test_generate_dockerfile_unknown(): - from acorn.template_engine import _generate_dockerfile content = _generate_dockerfile("unknown", {}) - assert "FROM unknown" in content + assert "FROM" in content def test_generate_gitignore_go(): - from acorn.template_engine import _generate_gitignore - content = _generate_gitignore("go") - assert "vendor/" in content + from acorn.generators.builtin import generate_file_content as gc + content = gc(".gitignore", "go") + assert "*.exe" in content def test_generate_gitignore_rust(): - from acorn.template_engine import _generate_gitignore - content = _generate_gitignore("rust") + from acorn.generators.builtin import generate_file_content as gc + content = gc(".gitignore", "rust") assert "target/" in content def test_generate_gitignore_default(): - from acorn.template_engine import _generate_gitignore - content = _generate_gitignore("unknown") - assert ".env" in content + from acorn.generators.builtin import generate_file_content as gc + content = gc(".gitignore", "unknown") + assert ".DS_Store" in content def test_generate_makefile(): @@ -970,84 +975,14 @@ def test_generate_from_template_files_dir_regenerate_no_backup(tmp_path): generate_from_template("test", out_dir, options) -def test_generate_cursorrules_with_ai_context(tmp_path): - from acorn.models import AIContext, CursorRules - tpl_path = tmp_path / "tpl" - tpl_path.mkdir() - (tpl_path / "template.yaml").write_text( - "name: test\ndescription: x\nversion: 1.0\ntype: node\nfiles: []\n" - ) - with patch("acorn.template_engine.find_template_by_name") as mock_find: - tpl = Template( - name="test", - path=tpl_path, - project_type="node", - files=[], - ai_context=AIContext( - cursor_rules=CursorRules( - tech_stack="Node.js 20, Express 4", - conventions=["Use CommonJS modules", "Error handling with try/catch"], - ) - ), - ) - mock_find.return_value = tpl - with patch("acorn.template_engine.resolve_template", return_value=tpl): - out_dir = tmp_path / "out" - out_dir.mkdir() - options = GenerationOptions() - generate_from_template("test", out_dir, options) - cursor_file = out_dir / ".cursorrules" - assert cursor_file.exists() - content = cursor_file.read_text() - assert "Node.js 20, Express 4" in content - assert "Use CommonJS modules" in content - assert "Error handling with try/catch" in content - - -def test_generate_cursorrules_skips_when_no_ai_context(tmp_path): - tpl_path = tmp_path / "tpl" - tpl_path.mkdir() - (tpl_path / "template.yaml").write_text( - "name: test\ndescription: x\nversion: 1.0\ntype: node\nfiles: []\n" - ) - with patch("acorn.template_engine.find_template_by_name") as mock_find: - tpl = Template(name="test", path=tpl_path, project_type="node", files=[]) - mock_find.return_value = tpl - with patch("acorn.template_engine.resolve_template", return_value=tpl): - out_dir = tmp_path / "out" - out_dir.mkdir() - options = GenerationOptions() - generate_from_template("test", out_dir, options) - cursor_file = out_dir / ".cursorrules" - assert not cursor_file.exists() +def test_generate_cursorrules_from_builtin(tmp_path): + from acorn.generators.builtin import generate_file_content as gc + content = gc(".cursorrules", "node") + assert "Tech Stack" in content + assert "Node.js" in content or "node" in content.lower() -def test_generate_cursorrules_skips_existing(tmp_path): - from acorn.models import AIContext, CursorRules - tpl_path = tmp_path / "tpl" - tpl_path.mkdir() - (tpl_path / "template.yaml").write_text( - "name: test\ndescription: x\nversion: 1.0\ntype: node\nfiles: []\n" - ) - with patch("acorn.template_engine.find_template_by_name") as mock_find: - tpl = Template( - name="test", - path=tpl_path, - project_type="node", - files=[], - ai_context=AIContext( - cursor_rules=CursorRules( - tech_stack="Node.js 20", - conventions=[], - ) - ), - ) - mock_find.return_value = tpl - with patch("acorn.template_engine.resolve_template", return_value=tpl): - out_dir = tmp_path / "out" - out_dir.mkdir() - (out_dir / ".cursorrules").write_text("existing") - options = GenerationOptions() - generate_from_template("test", out_dir, options) - cursor_content = (out_dir / ".cursorrules").read_text() - assert cursor_content == "existing" +def test_generate_cursorrules_uses_insights(tmp_path): + from acorn.generators.builtin import generate_file_content as gc + content = gc(".cursorrules", "python") + assert "Tech Stack" in content diff --git a/tests/test_wizard_and_tools.py b/tests/test_wizard_and_tools.py index d1941fc..2521be0 100644 --- a/tests/test_wizard_and_tools.py +++ b/tests/test_wizard_and_tools.py @@ -23,7 +23,7 @@ def test_wizard_completes_with_defaults(self, tmp_path: Path) -> None: with ( patch("builtins.input", lambda _="": next(inputs)), - patch("acorn.cli.cmd_generate", return_value=0), + patch("acorn.commands.generate.cmd_generate", return_value=0), patch("acorn.wizard.Path.cwd", return_value=tmp_path), ): rc = cmd_wizard() @@ -39,7 +39,7 @@ def test_wizard_resume_from_checkpoint(self, tmp_path: Path) -> None: with ( patch("builtins.input", lambda _="": next(inputs)), - patch("acorn.cli.cmd_generate", return_value=0), + patch("acorn.commands.generate.cmd_generate", return_value=0), patch("acorn.wizard.Path.cwd", return_value=tmp_path), ): rc = cmd_wizard() @@ -54,7 +54,7 @@ def test_wizard_reset_flag(self) -> None: inputs = iter(["", "1", "", "", "", "n", "n"]) with ( patch("builtins.input", lambda _="": next(inputs)), - patch("acorn.cli.cmd_generate", return_value=0), + patch("acorn.commands.generate.cmd_generate", return_value=0), ): rc = cmd_wizard(reset=True) assert rc == 0 @@ -68,7 +68,7 @@ def test_dockerize_node_project(self, tmp_path: Path) -> None: args = argparse.Namespace(dir=str(tmp_path), force=False, dry_run=False, regenerate=False, verbose=False, debug=False, quiet=False, offline=False, lang="en", config=None) - with patch("acorn.cli.detect_project_type") as mock_detect: + with patch("acorn.commands.docker.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", @@ -96,7 +96,7 @@ def test_dockerize_skips_existing(self, tmp_path: Path) -> None: args = argparse.Namespace(dir=str(tmp_path), force=False, dry_run=False, regenerate=False, verbose=False, debug=False, quiet=False, offline=False, lang="en", config=None) - with patch("acorn.cli.detect_project_type") as mock_detect: + with patch("acorn.commands.docker.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", @@ -116,7 +116,7 @@ def test_dockerize_force(self, tmp_path: Path) -> None: args = argparse.Namespace(dir=str(tmp_path), force=True, dry_run=False, regenerate=False, verbose=False, debug=False, quiet=False, offline=False, lang="en", config=None) - with patch("acorn.cli.detect_project_type") as mock_detect: + with patch("acorn.commands.docker.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", @@ -136,7 +136,7 @@ def test_add_ci_github_actions(self, tmp_path: Path) -> None: args = argparse.Namespace(dir=str(tmp_path), force=False, dry_run=False, verbose=False, debug=False, quiet=False, offline=False, lang="en", config=None) - with patch("acorn.cli.detect_project_type") as mock_detect: + with patch("acorn.commands.docker.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", @@ -313,7 +313,7 @@ def test_cmd_validate_ai_context_all_valid(): ), ), ] - with patch("acorn.cli.load_templates", return_value=templates): + with patch("acorn.commands.template_cmd.load_templates", return_value=templates): rc = cmd_validate_ai_context() assert rc == 0 @@ -330,7 +330,7 @@ def test_cmd_validate_ai_context_missing_field(): ), ), ] - with patch("acorn.cli.load_templates", return_value=templates): + with patch("acorn.commands.template_cmd.load_templates", return_value=templates): rc = cmd_validate_ai_context() assert rc != 0 @@ -345,7 +345,7 @@ def test_cmd_validate_ai_context_no_ai_context(): ai_context=None, ), ] - with patch("acorn.cli.load_templates", return_value=templates): + with patch("acorn.commands.template_cmd.load_templates", return_value=templates): rc = cmd_validate_ai_context() assert rc != 0 From aaa2b86cdf37360a4ff32bef57e9911ed063e73c Mon Sep 17 00:00:00 2001 From: "Gabriel.Foo" Date: Fri, 15 May 2026 23:14:45 +0800 Subject: [PATCH 2/2] chore: update Homebrew formula and CI for v0.2.0 - Formula: description + version 0.2.0 - CI: hidden-import for new Phase 1 modules (json_output, analysis.*, commands.*, generators.builtin) --- .github/workflows/ci.yml | 7 +++++++ .github/workflows/release.yml | 7 +++++++ Formula/acorn.rb | 4 ++-- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67f1deb..6925f4f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,13 @@ jobs: --add-data "src/acorn/templates:acorn/templates" \ --add-data "src/acorn/detectors:acorn/detectors" \ --add-data "src/acorn/locales:acorn/locales" \ + --hidden-import acorn.json_output \ + --hidden-import acorn.analysis.health \ + --hidden-import acorn.analysis.health_rules \ + --hidden-import acorn.analysis.insights \ + --hidden-import acorn.commands.doctor \ + --hidden-import acorn.commands.fix \ + --hidden-import acorn.generators.builtin \ src/acorn/cli.py - name: Smoke test run: ./dist/acorn --help diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06de2b9..e69e3ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,6 +39,13 @@ jobs: --add-data "src/acorn/templates:acorn/templates" \ --add-data "src/acorn/detectors:acorn/detectors" \ --add-data "src/acorn/locales:acorn/locales" \ + --hidden-import acorn.json_output \ + --hidden-import acorn.analysis.health \ + --hidden-import acorn.analysis.health_rules \ + --hidden-import acorn.analysis.insights \ + --hidden-import acorn.commands.doctor \ + --hidden-import acorn.commands.fix \ + --hidden-import acorn.generators.builtin \ src/acorn/cli.py - name: Smoke test shell: bash diff --git a/Formula/acorn.rb b/Formula/acorn.rb index e680e99..8b4c440 100644 --- a/Formula/acorn.rb +++ b/Formula/acorn.rb @@ -1,8 +1,8 @@ class Acorn < Formula - desc "Smart project initialization tool — auto-detect, template-based scaffolding" + desc "AI coding environment optimizer — auto-detect, diagnose, and fix your project setup" homepage "https://github.com/SilasFu/Acorn" license "MIT" - version "0.1.0" + version "0.2.0" on_macos do if Hardware::CPU.arm?