#!/usr/bin/env python3 """Analyze a project and produce facts used by the copyright material workflow.""" from __future__ import annotations import argparse import re from collections import Counter from pathlib import Path from typing import Any from common import COPYRIGHT_CODE_EXTS, FRONTEND_EXTS, count_text_lines, is_known_config_file, iter_project_files, normalize_title, read_json, read_text, rel, write_json DEPENDENCY_FRAMEWORKS = { "vue": "Vue", "@vue/runtime-core": "Vue", "react": "React", "next": "Next.js", "nuxt": "Nuxt", "svelte": "Svelte", "astro": "Astro", "@angular/core": "Angular", "vite": "Vite", "uni-app": "UniApp", "@dcloudio/uni-app": "UniApp", "electron": "Electron", "@tauri-apps/api": "Tauri", } ENTRY_NAMES = { "main.ts", "main.js", "main.tsx", "main.jsx", "index.tsx", "index.jsx", "app.vue", "App.vue", "app.tsx", } def load_package(project: Path) -> tuple[dict[str, Any] | None, Path | None]: candidates = [ project / "package.json", project / "frontend/package.json", project / "client/package.json", project / "web/package.json", project / "app/package.json", ] for package_path in candidates: if not package_path.exists(): continue try: return read_json(package_path), package_path except Exception: continue return None, None def detect_frameworks(package: dict[str, Any] | None, files: list[Path], project: Path) -> list[str]: found: set[str] = set() deps: dict[str, Any] = {} if package: for key in ("dependencies", "devDependencies", "peerDependencies"): deps.update(package.get(key) or {}) for dep in deps: if dep in DEPENDENCY_FRAMEWORKS: found.add(DEPENDENCY_FRAMEWORKS[dep]) suffixes = {p.suffix.lower() for p in files} if ".vue" in suffixes: found.add("Vue") if ".tsx" in suffixes or ".jsx" in suffixes: if "Vue" not in found: found.add("React") if (project / "vite.config.ts").exists() or (project / "vite.config.js").exists(): found.add("Vite") if (project / "next.config.js").exists() or (project / "next.config.ts").exists() or any(p.name in {"next.config.js", "next.config.ts"} for p in files): found.add("Next.js") return sorted(found) def classify(path: Path, project: Path) -> str: r = rel(path, project).lower() name = path.name if name in ENTRY_NAMES or r in {"src/app/page.tsx", "src/app/layout.tsx", "app/page.tsx", "app/layout.tsx"} or r.endswith("/src/app/page.tsx") or r.endswith("/src/app/layout.tsx"): return "entry" if path.suffix.lower() in {".css", ".scss", ".sass", ".less"}: return "style" if any(part in r for part in ("/router/", "/routes/", "router.", "routes.")): return "route" if any(part in r for part in ("/pages/", "/views/", "/app/", "/screens/")): return "page" if "/components/" in r: return "component" if any(part in r for part in ("/api/", "/apis/", "/services/", "request.", "/request/", "/controllers/", "/handlers/", "/views.py")): return "api" if any(part in r for part in ("/models/", "/schemas/", "/entities/", "/repositories/", "/dao/")): return "model" if any(part in r for part in ("/store/", "/stores/", "/pinia/", "/redux/", "/zustand/")): return "state" if any(part in r for part in ("/utils/", "/lib/", "/hooks/", "/composables/", "/helpers/")): return "utility" return "source" def extract_route_paths(path: Path) -> list[str]: try: text = read_text(path, limit=200_000) except Exception: return [] patterns = [ r"path\s*:\s*['\"]([^'\"]+)['\"]", r"]+path=['\"]([^'\"]+)['\"]", r"href=['\"](/[^'\"]*)['\"]", ] routes: list[str] = [] for pattern in patterns: for match in re.findall(pattern, text): if match.startswith("/") and len(match) < 120 and "*" not in match: routes.append(match) return routes def summarize_readme(project: Path) -> str: for name in ("README.md", "README.zh.md", "readme.md", "Readme.md"): path = project / name if path.exists(): text = read_text(path, limit=4000) return "\n".join(line.strip() for line in text.splitlines()[:60] if line.strip()) return "" def analyze(project: Path) -> dict[str, Any]: project = project.resolve() package, package_path = load_package(project) source_files = [p for p in iter_project_files(project, COPYRIGHT_CODE_EXTS) if not is_known_config_file(p)] frontend_files = [p for p in source_files if p.suffix.lower() in FRONTEND_EXTS] class_counts: Counter[str] = Counter() extension_counts: Counter[str] = Counter() source_lines = 0 total_source_lines = 0 categorized: dict[str, list[str]] = { "entry": [], "route": [], "page": [], "component": [], "api": [], "model": [], "state": [], "utility": [], "style": [], "source": [], } route_paths: list[str] = ["/"] for path in source_files: category = classify(path, project) class_counts[category] += 1 extension_counts[path.suffix.lower()] += 1 categorized[category].append(rel(path, project)) source_lines += count_text_lines(path, skip_blank=False) if path.suffix.lower() in FRONTEND_EXTS and category in {"route", "page", "entry"}: route_paths.extend(extract_route_paths(path)) total_source_lines = source_lines package_name = "" scripts: dict[str, str] = {} dependencies: dict[str, str] = {} if package: package_name = str(package.get("name") or "") scripts = {k: str(v) for k, v in (package.get("scripts") or {}).items()} for key in ("dependencies", "devDependencies"): dependencies.update({k: str(v) for k, v in (package.get(key) or {}).items()}) frameworks = detect_frameworks(package, frontend_files, project) language = infer_language(extension_counts, frameworks) route_paths = sorted(set(route_paths), key=lambda x: (x.count("/"), x)) return { "project_root": str(project), "project_name": project.name, "software_name_candidate": normalize_title(package_name or project.name), "package": { "name": package_name, "path": rel(package_path, project) if package_path else "", "version": str(package.get("version") or "V1.0") if package else "V1.0", "scripts": scripts, "dependency_names": sorted(dependencies), }, "frameworks": frameworks, "language": language, "source": { "file_count": len(source_files), "line_count": source_lines, "total_file_count": len(source_files), "total_line_count": total_source_lines, "extension_counts": dict(sorted(extension_counts.items())), "category_counts": dict(sorted(class_counts.items())), "categorized_files": {k: v[:80] for k, v in categorized.items() if v}, }, "routes": route_paths[:80], "readme_excerpt": summarize_readme(project), "run_command_candidates": infer_run_commands(scripts), "feature_candidates": infer_features(categorized, route_paths), } def infer_workdir(out: Path) -> Path: if out.parent.name == "analysis": return out.parent.parent return out.parent def check_environment_gate(out: Path) -> None: workdir = infer_workdir(out) env_path = workdir / "环境检查.json" if not env_path.exists(): return env = read_json(env_path) if not env.get("requires_user_input"): return confirmation_path = workdir / "环境确认.json" confirmed = False if confirmation_path.exists(): confirmed = bool(read_json(confirmation_path).get("environment_confirmed")) if not confirmed: raise SystemExit( "STOP_FOR_USER\n" "NEXT_ACTION: 完整 DOCX 环境未确认。请先让用户选择安装完整环境或使用基础 DOCX 兜底继续," "然后运行 `python3 /scripts/confirm_stage.py --workdir 软件著作权申请资料 --stage environment --note \"<用户选择>\"`。" ) def infer_language(extension_counts: Counter[str], frameworks: list[str]) -> str: langs: list[str] = [] if extension_counts.get(".ts") or extension_counts.get(".tsx"): langs.append("TypeScript") if extension_counts.get(".js") or extension_counts.get(".jsx"): langs.append("JavaScript") language_by_ext = { ".py": "Python", ".java": "Java", ".go": "Go", ".rs": "Rust", ".cs": "C#", ".php": "PHP", ".rb": "Ruby", ".kt": "Kotlin", ".swift": "Swift", ".sql": "SQL", ".sh": "Shell", } for ext, label in language_by_ext.items(): if extension_counts.get(ext): langs.append(label) if not langs: langs = [ext.lstrip(".").upper() for ext, _ in extension_counts.most_common(3) if ext] return "、".join(dict.fromkeys(langs)) or "待用户确认" def infer_run_commands(scripts: dict[str, str]) -> list[str]: preferred = ["dev", "start", "serve", "preview"] commands = [] for name in preferred: if name in scripts: commands.append(f"npm run {name}") return commands def infer_features(categorized: dict[str, list[str]], routes: list[str]) -> list[str]: stop = { "index", "main", "app", "layout", "page", "globals", "providers", "loading", "error", "not-found", "template", "default", "button", "input", "label", "avatar", "card", "textarea", "scroll area", } names: list[str] = [] for route in routes: cleaned = route.strip("/").replace("-", " ").replace("_", " ") if cleaned and not cleaned.startswith(":") and cleaned.lower() not in stop: names.append(cleaned) for file in categorized.get("page", [])[:60]: route_name = feature_from_page_path(file) if route_name and route_name.lower() not in stop: names.append(route_name) for category in ("api", "component"): for file in categorized.get(category, [])[:30]: lowered = file.lower() if "/ui/" in lowered or "/components/ui/" in lowered: continue stem = Path(file).stem normalized = stem.replace("-", " ").replace("_", " ").strip() if normalized.lower() not in stop: names.append(normalized) unique: list[str] = [] for name in names: normalized = re.sub(r"\s+", " ", name).strip() if normalized and normalized not in unique: unique.append(normalized) return unique[:30] def feature_from_page_path(file: str) -> str: parts = Path(file).parts useful: list[str] = [] for part in parts: if part in {"src", "app", "pages", "views", "screens", "frontend", "client", "web"}: continue if part.startswith("(") and part.endswith(")"): continue stem = Path(part).stem if stem in {"page", "layout", "index", "route", "loading", "error", "globals", "providers"}: continue if stem.startswith("[") and stem.endswith("]"): continue useful.append(stem) return " ".join(useful[-2:]).replace("-", " ").replace("_", " ").strip() def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--project", required=True, help="Project root directory") parser.add_argument("--out", default="软件著作权申请资料/analysis/project.json") args = parser.parse_args() project = Path(args.project) if not project.exists(): raise SystemExit(f"Project not found: {project}") out = Path(args.out) check_environment_gate(out) result = analyze(project) write_json(out, result) print(f"OK analysis: {out}") print(f"Project: {result['project_name']}") print(f"Frameworks: {', '.join(result['frameworks']) or 'unknown'}") print(f"Language: {result['language']}") print(f"Source files: {result['source']['file_count']}") print(f"Source lines: {result['source']['line_count']}") print(f"Total source files: {result['source']['total_file_count']}") print(f"Total source lines: {result['source']['total_line_count']}") if __name__ == "__main__": main()