#!/usr/bin/env python3 """Shared helpers for the software copyright materials skill.""" from __future__ import annotations import json import os import re from pathlib import Path from typing import Any, Iterable EXCLUDE_DIRS = { ".git", ".hg", ".svn", ".idea", ".vscode", "__pycache__", "node_modules", "dist", "build", ".next", ".nuxt", ".output", "coverage", "target", "vendor", "软件著作权申请资料", "software-copyright-materials", } CODE_EXTS = { ".vue", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".css", ".scss", ".sass", ".less", ".html", ".svelte", ".astro", ".json", ".md", } KNOWN_CONFIG_FILES = { ".babelrc", ".eslintrc", ".eslintrc.json", ".eslintrc.yaml", ".eslintrc.yml", ".prettierrc", ".prettierrc.json", ".prettierrc.yaml", ".prettierrc.yml", ".swcrc", "angular.json", "app.json", "astro.config.mjs", "astro.config.ts", "babel.config.js", "babel.config.json", "Cargo.lock", "Cargo.toml", "composer.json", "docker-compose.yaml", "docker-compose.yml", "eslint.config.cjs", "eslint.config.js", "eslint.config.mjs", "go.mod", "go.sum", "jsconfig.json", "lerna.json", "manifest.json", "next.config.js", "next.config.mjs", "next.config.ts", "nuxt.config.js", "nuxt.config.ts", "nx.json", "package-lock.json", "package.json", "playwright.config.js", "playwright.config.ts", "postcss.config.cjs", "postcss.config.js", "prettier.config.cjs", "prettier.config.js", "prettier.config.mjs", "project.json", "pyproject.toml", "rollup.config.js", "rollup.config.mjs", "rollup.config.ts", "svelte.config.js", "stylelintrc.json", "tailwind.config.js", "tailwind.config.ts", "tsconfig.app.json", "tsconfig.json", "tsconfig.node.json", "tslint.json", "turbo.json", "vite.config.js", "vite.config.mjs", "vite.config.ts", "vitest.config.js", "vitest.config.ts", "webpack.config.js", "webpack.config.ts", "workspace.json", } FRONTEND_EXTS = { ".vue", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".css", ".scss", ".sass", ".less", ".html", ".svelte", ".astro", } SUPPLEMENT_CODE_EXTS = { ".py", ".java", ".go", ".rs", ".cs", ".php", ".rb", ".kt", ".swift", ".sql", ".sh", ".json", } COPYRIGHT_CODE_EXTS = FRONTEND_EXTS | SUPPLEMENT_CODE_EXTS LOCK_FILES = { "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "bun.lock", } def repo_root_from_script() -> Path: return Path(__file__).resolve().parents[3] def is_excluded(path: Path) -> bool: parts = set(path.parts) if parts & EXCLUDE_DIRS: return True name = path.name if name.startswith(".") and name not in {".env.example"}: return True if name in LOCK_FILES: return True if name.endswith(".map") or name.endswith(".min.js") or name.endswith(".min.css"): return True return False def iter_project_files(project: Path, exts: set[str] | None = None) -> Iterable[Path]: project = project.resolve() for root, dirs, files in os.walk(project): root_path = Path(root) dirs[:] = [d for d in dirs if not is_excluded(root_path / d)] for filename in files: path = root_path / filename if is_excluded(path): continue if exts is not None and path.suffix.lower() not in exts: continue yield path def rel(path: Path, root: Path) -> str: return path.resolve().relative_to(root.resolve()).as_posix() def read_text(path: Path, limit: int | None = None) -> str: data = path.read_bytes() if limit is not None: data = data[:limit] for encoding in ("utf-8", "utf-8-sig", "gb18030", "latin-1"): try: return data.decode(encoding) except UnicodeDecodeError: continue return data.decode("utf-8", errors="replace") def read_json(path: Path) -> dict[str, Any]: return json.loads(read_text(path)) def write_json(path: Path, data: Any) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") def count_text_lines(path: Path, skip_blank: bool = True) -> int: try: text = read_text(path) except Exception: return 0 if not text: return 0 if skip_blank: return sum(1 for line in text.splitlines() if line.strip()) return len(text.splitlines()) def is_known_config_file(path: Path) -> bool: """Return True for well-known config files that shouldn't count as source code.""" return path.name in KNOWN_CONFIG_FILES def looks_binary(path: Path) -> bool: try: chunk = path.read_bytes()[:4096] except Exception: return True return b"\x00" in chunk def normalize_title(value: str) -> str: value = re.sub(r"[-_]+", " ", value).strip() value = re.sub(r"\s+", " ", value) return value or "待命名软件" def safe_filename(value: str) -> str: value = re.sub(r'[\\/:*?"<>|]+', "_", value).strip() return value or "软件" def ensure_dir(path: Path) -> Path: path.mkdir(parents=True, exist_ok=True) return path