Files
2026-07-13 12:29:52 +08:00

364 lines
13 KiB
Python

#!/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"<Route[^>]+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 <SKILL_DIR>/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()