Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog / 更新日志

## [0.3.0] - 2026-05-15

- Phase 2: Deep AI context generation with 15+ analysis dimensions (import style, module system, naming convention, state management, styling approach, API style, architecture pattern, directory purposes). / 深度 AI 上下文生成,15+ 分析维度。
- Phase 2: Rich `.cursorrules`, `CLAUDE.md`, `copilot-instructions.md` with project structure, API routes, and tech stack details. / 富内容 AI 上下文文件,包含项目结构、API 路由和技术栈详情。
- Phase 2: `analysis/ast_lite.py` — regex-based code analysis engine. / 基于正则的代码分析引擎。
- Phase 3: `acorn sync` — detect and fix stale AI context files. / 检测并修复过时的 AI 上下文文件。
- Phase 3: `acorn sync --sync-hook` — install pre-commit git hook. / 安装 pre-commit git 钩子。

## [0.2.0] - 2026-05-15

- Phase 1: Pivot to AI coding environment optimizer. / 转型为 AI 编码环境优化器。
- `acorn doctor` — 7-check health report with auto-fix prompt. / 7 项健康检查 + 自动修复。
- `acorn fix` — targeted file generation (Dockerfile, AI files, gitignore, etc.). / 定向文件生成。
- Unified `generators/builtin.py` with 9-language conventions. / 统一内置生成器,9 种语言约定。
- `acorn` no-args → doctor (if source exists) or wizard (if empty). / 无参数行为变更。

## [0.1.0] - 2024-01-01

- Initial alpha release. / 初始 Alpha 版本。
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "acorn"
version = "0.2.0"
version = "0.3.0"
description = "智能项目初始化工具 - 自动检测项目类型、匹配模板、生成配置"
readme = "README.md"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion src/acorn/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.2.0"
__version__ = "0.3.0"
279 changes: 279 additions & 0 deletions src/acorn/analysis/ast_lite.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
from __future__ import annotations

import re
from pathlib import Path

SOURCE_EXTENSIONS_TS = {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"}
MAX_SCAN_FILES = 50


def _iter_source(dir_path: Path, max_files: int = MAX_SCAN_FILES) -> list[Path]:
count = 0
results = []
for f in dir_path.rglob("*"):
if f.suffix in SOURCE_EXTENSIONS_TS and f.is_file():
results.append(f)
count += 1
if count >= max_files:
break
return results


def _read_safe(path: Path) -> str | None:
try:
return path.read_text("utf-8", errors="ignore")
except OSError:
return None


def detect_import_style(dir_path: Path) -> str:
path_alias_patterns = [
re.compile(r'from\s+["\']@/'),
re.compile(r'from\s+["\']~/'),
re.compile(r'from\s+["\']\$/'),
re.compile(r'from\s+["\']@\w+/'),
]
for f in _iter_source(dir_path):
content = _read_safe(f)
if not content:
continue
if any(p.search(content) for p in path_alias_patterns):
return "path-alias"
if re.search(r'from\s+["\']\.\.?/', content):
return "relative"
return "unknown"


def detect_module_system(dir_path: Path) -> str:
has_esm_import = False
has_cjs_require = False
for f in _iter_source(dir_path):
content = _read_safe(f)
if not content:
continue
if re.search(r'^\s*import\s+', content, re.MULTILINE):
has_esm_import = True
if re.search(r'^\s*(?:const|let|var)\s+\w+\s*=\s*require\(', content, re.MULTILINE):
has_cjs_require = True
pkg = _find_nearest_package_json(f)
if pkg and _read_safe(pkg):
import json
try:
data = json.loads(_read_safe(pkg) or "{}")
if data.get("type") == "module":
return "esm"
if data.get("type") == "commonjs":
return "commonjs"
except (json.JSONDecodeError, OSError):
pass
if has_esm_import and not has_cjs_require:
return "esm"
if has_cjs_require and not has_esm_import:
return "commonjs"
return "mixed" if has_esm_import and has_cjs_require else "unknown"


def _find_nearest_package_json(path: Path) -> Path | None:
for parent in [path] + list(path.parents):
candidate = parent / "package.json"
if candidate.is_file():
return candidate
return None


def detect_naming_convention(dir_path: Path) -> str:
scores = {"camelCase": 0, "snake_case": 0, "kebab-case": 0, "PascalCase": 0}
for f in _iter_source(dir_path):
name = f.stem
if re.match(r'^[a-z]+[a-zA-Z0-9]*$', name):
scores["camelCase"] += 1
elif re.match(r'^[a-z]+(_[a-z0-9]+)*$', name):
scores["snake_case"] += 1
elif re.match(r'^[a-z]+(-[a-z0-9]+)*$', name):
scores["kebab-case"] += 1
elif re.match(r'^[A-Z][a-zA-Z0-9]*$', name):
scores["PascalCase"] += 1
best = max(scores, key=scores.get)
return best if scores[best] > 0 else "unknown"


def detect_state_management(dir_path: Path) -> str | None:
pkg_json = dir_path / "package.json"
if not pkg_json.is_file():
return None
content = _read_safe(pkg_json)
if not content:
return None
import json
try:
data = json.loads(content)
deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
except json.JSONDecodeError:
return None
sm_map = {
"zustand": "Zustand", "redux": "Redux", "@reduxjs/toolkit": "Redux Toolkit",
"pinia": "Pinia", "valtio": "Valtio", "jotai": "Jotai",
"recoil": "Recoil", "mobx": "MobX", "mobx-react": "MobX",
"vuex": "Vuex", "ngrx": "NgRx",
}
for dep, label in sm_map.items():
if dep in deps:
return label
return None


def detect_styling_approach(dir_path: Path) -> str | None:
config_files = [
"tailwind.config.js", "tailwind.config.ts", "tailwind.config.cjs",
"unocss.config.ts", "unocss.config.js",
]
for cf in config_files:
if (dir_path / cf).is_file():
return "tailwindcss" if cf.startswith("tailwind") else "unocss"
pkg_json = dir_path / "package.json"
if pkg_json.is_file():
content = _read_safe(pkg_json)
if content:
import json
try:
data = json.loads(content)
deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
except json.JSONDecodeError:
deps = {}
style_map = {
"tailwindcss": "tailwindcss", "unocss": "unocss",
"styled-components": "styled-components",
"@emotion/react": "emotion", "@emotion/styled": "emotion",
"sass": "sass", "less": "less", "stylus": "stylus",
"@stitches/react": "Stitches",
}
for dep, label in style_map.items():
if dep in deps:
return label
if list(dir_path.rglob("*.module.css")):
return "css-modules"
return None


def detect_api_style(dir_path: Path) -> str | None:
pkg_json = dir_path / "package.json"
if pkg_json.is_file():
content = _read_safe(pkg_json)
if content:
import json
try:
data = json.loads(content)
deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
except json.JSONDecodeError:
deps = {}
if "@trpc/client" in deps or "@trpc/server" in deps or "trpc" in deps:
return "tRPC"
if "graphql" in deps or "@graphql-codegen" in deps:
return "GraphQL"
if "grpc" in deps or "@grpc/grpc-js" in deps:
return "gRPC"
for f in _iter_source(dir_path):
content = _read_safe(f)
if not content:
continue
if "tRPC" in content or "trpc" in content:
return "tRPC"
if list(dir_path.rglob("*.graphql")) or list(dir_path.rglob("*.gql")):
return "GraphQL"
if pkg_json.is_file():
if list(dir_path.rglob("route.ts")) or list(dir_path.rglob("route.js")):
return "REST (file-based)"
return None


def detect_architecture_pattern(dir_path: str | Path, language: str) -> str | None:
dir_path = Path(dir_path) if isinstance(dir_path, str) else dir_path
if language == "node":
if (dir_path / "app").is_dir() or (dir_path / "src/app").is_dir():
return "app-router"
if (dir_path / "pages").is_dir() or (dir_path / "src/pages").is_dir():
return "pages-router"
if (dir_path / "lerna.json").is_file() or (dir_path / "pnpm-workspace.yaml").is_file():
return "monorepo"
if (dir_path / "turbo.json").is_file():
return "monorepo (turborepo)"
if (dir_path / "nx.json").is_file():
return "monorepo (nx)"
if language == "python":
if (dir_path / "apps").is_dir() and (dir_path / "pyproject.toml").is_file():
return "monorepo"
return None


def detect_entry_points(dir_path: Path, language: str) -> list[str]:
patterns = {
"node": ["index.js", "index.ts", "app.js", "app.ts", "server.js", "server.ts", "main.js", "main.ts"],
"python": ["main.py", "app.py", "wsgi.py", "manage.py", "run.py"],
"go": ["main.go", "cmd/server/main.go", "cmd/api/main.go"],
"rust": ["src/main.rs"],
"java": ["src/main/java/**/Application.java"],
"ruby": ["app.rb", "config.ru", "bin/rails"],
"php": ["public/index.php", "index.php", "artisan"],
}
found = []
for pattern in patterns.get(language, []):
if "*" in pattern:
for f in dir_path.glob(pattern):
found.append(str(f.relative_to(dir_path)))
else:
f = dir_path / pattern
if f.is_file():
found.append(pattern)
return found


def detect_directory_purpose(structure: dict[str, list[str]], language: str) -> dict[str, str]:
purposes: dict[str, str] = {}
known: dict[str, dict[str, str]] = {
"node": {
"app": "Route handlers and pages",
"pages": "Page components",
"components": "Reusable React components",
"lib": "Utility functions and shared logic",
"utils": "Utility functions",
"server": "Server-side code",
"api": "API route handlers",
"hooks": "Custom React hooks",
"styles": "CSS/style files",
"public": "Static assets",
"types": "TypeScript type definitions",
"db": "Database schema and queries",
"config": "Configuration files",
"middleware": "Express/Koa middleware",
"routes": "Route definitions",
"controllers": "Controller logic",
"models": "Data models",
"services": "Business logic layer",
"validators": "Input validation",
"tests": "Test files",
"migrations": "Database migrations",
"seeds": "Database seed data",
},
"python": {
"app": "Application entry point",
"api": "API route handlers",
"routes": "Route definitions",
"models": "SQLAlchemy/Django models",
"schemas": "Pydantic/marshmallow schemas",
"services": "Business logic layer",
"utils": "Utility functions",
"tests": "Test files",
"migrations": "Database migrations",
"config": "Configuration",
"middleware": "Middleware",
"templates": "Jinja2 templates",
},
}
for top_dir, files in structure.items():
for lang_key, mapping in known.items():
if language == lang_key and top_dir in mapping:
purposes[top_dir] = mapping[top_dir]
break
else:
purposes[top_dir] = "Unknown"
return purposes
20 changes: 20 additions & 0 deletions src/acorn/analysis/insights.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from pathlib import Path

from acorn._compat import tomllib
from acorn.analysis import ast_lite

SOURCE_EXTENSIONS = {".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".java", ".rb", ".php", ".c", ".cpp", ".h", ".hpp"}
IGNORE_DIRS = {
Expand Down Expand Up @@ -37,6 +38,15 @@ class ProjectInsights:

entry_points: list[str] = field(default_factory=list)

import_style: str = "unknown"
module_system: str = "unknown"
naming_convention: str = "unknown"
state_management: str | None = None
styling_approach: str | None = None
api_style: str | None = None
architecture_pattern: str | None = None
directory_purposes: dict[str, str] = field(default_factory=dict)


_JS_FRAMEWORKS: dict[str, tuple[str | None, str | None, str | None]] = {
"next": ("Next.js", None, None),
Expand Down Expand Up @@ -333,6 +343,16 @@ def analyze(dir_path: Path | str) -> ProjectInsights:
_analyze_src_structure(ins, dir_path)
_find_entry_points(ins, dir_path)

if ins.language == "node":
ins.import_style = ast_lite.detect_import_style(dir_path)
ins.module_system = ast_lite.detect_module_system(dir_path)
ins.naming_convention = ast_lite.detect_naming_convention(dir_path)
ins.state_management = ast_lite.detect_state_management(dir_path)
ins.styling_approach = ast_lite.detect_styling_approach(dir_path)
ins.api_style = ast_lite.detect_api_style(dir_path)
ins.architecture_pattern = ast_lite.detect_architecture_pattern(dir_path, "node")
ins.directory_purposes = ast_lite.detect_directory_purpose(ins.src_structure, "node")

return ins


Expand Down
9 changes: 9 additions & 0 deletions src/acorn/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from acorn.commands.doctor import cmd_doctor
from acorn.commands.docker import cmd_add_ci, cmd_dockerize
from acorn.commands.fix import cmd_fix
from acorn.commands.sync import cmd_sync
from acorn.commands.generate import cmd_generate
from acorn.commands.marketplace import cmd_install, cmd_search
from acorn.commands.template_cmd import cmd_add, cmd_init, cmd_list, cmd_remove, cmd_validate, cmd_validate_ai_context
Expand Down Expand Up @@ -93,6 +94,10 @@ def build_parser() -> argparse.ArgumentParser:
g_admin.add_argument("--scan", metavar="PATH", help="扫描模板或项目的安全问题")
g_admin.add_argument("--config", metavar="FILE", help="指定全局配置文件路径")

g_sync = parser.add_argument_group("sync options")
g_sync.add_argument("--sync", action="store_true", help="同步 AI 上下文文件 / Sync AI context files")
g_sync.add_argument("--sync-hook", action="store_true", dest="sync_hook", help="安装 pre-commit hook / Install pre-commit hook")

g_fix = parser.add_argument_group("fix options")
g_fix.add_argument("--fix", action="store_true", help="修复项目配置(子命令模式)")
g_fix.add_argument("--fix-dockerfile", action="store_true", dest="fix_dockerfile", help="生成 Dockerfile")
Expand Down Expand Up @@ -143,6 +148,8 @@ def main() -> int:
sys.argv = [sys.argv[0], "--wizard"] + sys.argv[2:]
if len(sys.argv) >= 2 and sys.argv[1] == "fix":
sys.argv = [sys.argv[0], "--fix"] + sys.argv[2:]
if len(sys.argv) >= 2 and sys.argv[1] == "sync":
sys.argv = [sys.argv[0], "--sync"] + sys.argv[2:]

parser = build_parser()
args = parser.parse_args()
Expand Down Expand Up @@ -202,6 +209,8 @@ def main() -> int:
if args.validate:
return cmd_validate(args.validate)

if args.sync:
return cmd_sync(cwd=Path(args.dir).resolve(), hook_install=args.sync_hook, force=args.force, dry_run=args.dry_run)
if args.fix:
return cmd_fix(args)

Expand Down
Loading
Loading