diff --git a/.cursor/rules/copyright_header.mdc b/.cursor/rules/copyright_header.mdc new file mode 100644 index 0000000000..ac926ff1e6 --- /dev/null +++ b/.cursor/rules/copyright_header.mdc @@ -0,0 +1,10 @@ +--- +alwaysApply: true +description: Enforce the copyright header on new and modified source files +--- + +# Shared project rules + +Read [AGENTS.md](mdc:AGENTS.md) before working in this rule scope. + +Paths are relative to the repository root. If linked context is unavailable, read the file directly. Load only applicable references. Maintain the linked Markdown source instead of duplicating rules here. diff --git a/.github/workflows/copyright-check.yml b/.github/workflows/copyright-check.yml new file mode 100644 index 0000000000..89bc883fa7 --- /dev/null +++ b/.github/workflows/copyright-check.yml @@ -0,0 +1,67 @@ +name: Copyright Header Check + +concurrency: + group: copyright-check-${{ github.ref }} + cancel-in-progress: true + +on: + workflow_dispatch: + inputs: + check_mode: + description: 'check scope: all files or only files changed in the latest commit' + required: false + default: 'all' + type: choice + options: + - all + - changed + pull_request: + branches: [develop, 'release/**', 'hotfix/**'] + paths: + - '**.py' + - '**.sh' + - '**.sql' + - '**.ts' + - '**.tsx' + - '**.js' + - '**.jsx' + - '**.mjs' + - '.github/workflows/copyright-check.yml' + +permissions: + contents: read + +jobs: + copyright-check: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Collect changed files + if: github.event_name != 'workflow_dispatch' || github.event.inputs.check_mode == 'changed' + run: | + set -e + touch changed_files.txt + if [ "${{ github.event_name }}" = "pull_request" ]; then + git diff --name-only --diff-filter=ACMR "origin/${{ github.base_ref }}...HEAD" > changed_files.txt + else + git diff --name-only --diff-filter=ACMR HEAD~1..HEAD > changed_files.txt + fi + echo "---- changed files ----" + cat changed_files.txt + + - name: Check copyright headers on changed files + if: github.event_name != 'workflow_dispatch' || github.event.inputs.check_mode == 'changed' + run: python scripts/check_copyright.py --files-from changed_files.txt + + - name: Check copyright headers on all files + if: github.event_name == 'workflow_dispatch' && github.event.inputs.check_mode == 'all' + run: python scripts/check_copyright.py diff --git a/AGENTS.md b/AGENTS.md index d7808c2993..ec0a379d4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ ## Shared constraints - Write code comments, docstrings, TODOs, and configuration comments in English. User-facing strings may use supported languages; this does not prescribe conversation language. +- Every new or modified source file (`.py`, `.sh`, `.sql`, `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`) must start with the copyright header `Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved.` (the end year tracks the current year, e.g. `2025-2027` in 2027) in the file's comment style (`#` for Python/Shell, `--` for SQL, `//` for TypeScript/JavaScript), placed after shebang/encoding lines. The `Copyright Header Check` CI job rejects changed files missing it; verify or fix locally with `python scripts/check_copyright.py [--fix] `. - In backend/SDK Python code, centralize environment reads in `backend/consts/const.py`. Backend callers import from `consts.const`; SDK code accepts parameters and must not read environment variables or introduce `from_env()` methods. - Every SQL file under `deploy/sql/` already merged into the target branch is immutable, including initialization and Supabase SQL. Do not edit, rename, or delete it. Add versioned migrations under `deploy/sql/migrations/`; application version is `APP_VERSION` in `backend/consts/const.py`. - Preserve existing HTTP routes, payloads, and response contracts when applying conventions to new code. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2f0ebc0b79..70036c6dcc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,6 +85,30 @@ git checkout -b your-branch-name ### Step 4 Make Your Changes 🧙‍♂️ Code like a wizard! Follow our [Development Guide](https://modelengine-group.github.io/nexent/en/getting-started/development-guide) for setup instructions and coding standards. Ensure your changes are well-tested and documented. +### 📜 Copyright Header Requirement + +Every source file you add or modify must start with the standard copyright header. The CI **Copyright Header Check** job inspects all changed `.py`, `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`, `.sh` and `.sql` files on every PR, and the check fails when the header is missing. + +The header uses the comment style of the file type (`#` for Python/Shell, `--` for SQL, `//` for TypeScript/JavaScript): + +```python +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +``` + +The end year of the range always tracks the current year (e.g. `2025-2027` in 2027); the checker accepts any existing year variant, and `--fix` generates the current range automatically. + +To verify locally before pushing: + +```bash +python scripts/check_copyright.py --files path/to/your/file.py +``` + +Missing headers can also be inserted automatically: + +```bash +python scripts/check_copyright.py --fix path/to/your/file.py +``` + ### Step 5 Commit Your Changes 📝 Commit with a clear and concise message following our commit message guidelines: diff --git a/scripts/check_copyright.py b/scripts/check_copyright.py new file mode 100644 index 0000000000..c8a738302f --- /dev/null +++ b/scripts/check_copyright.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +# coding: utf-8 +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +"""Copyright header checker for the Nexent repository. + +Modes: + 1. Full scan (default): scan the whole repository for target files. + 2. Explicit files: pass paths via --files or --files-from (CI incremental mode). + 3. Fix mode: --fix inserts the standard header into files that lack it. + +Exit code 1 when any checked file is missing the required header in check mode. +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +from datetime import datetime +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# First year of the project's copyright convention; the header inserted by +# --fix carries a year range ending at the current year (see copyright_text()). +COPYRIGHT_START_YEAR = 2025 + +# Loose matching pattern so existing year variants keep passing, e.g. +# Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. +# Copyright (c) Huawei Technologies Co., Ltd. 2025-2025. All rights reserved +COPYRIGHT_PATTERN = re.compile( + r"Copyright\s*\(c\)\s*(?:\d{4}(?:\s*[-,\u2013]\s*\d{4})*\s*,?\s*)?" + r"Huawei\s+Technologies\s+Co\.,?\s*Ltd\.?", + re.IGNORECASE, +) + +# Encoding declaration lines such as "# -*- coding: utf-8 -*-" or "# coding: utf-8". +CODING_PATTERN = re.compile(r"^#\s*(?:-\*-\s*)?coding[:=]", re.IGNORECASE) + +# Comment prefix per target extension. +COMMENT_STYLES = { + ".py": "#", + ".sh": "#", + ".sql": "--", + ".js": "//", + ".jsx": "//", + ".mjs": "//", + ".ts": "//", + ".tsx": "//", +} + +# Search the header within the first N lines of the file. +HEADER_SEARCH_LINES = 10 +# Give up after N non-trivial content lines without a copyright match. +CONTENT_LINE_LIMIT = 5 + +# Directories never scanned in full-scan mode. +EXCLUDED_DIRS = { + ".git", + ".cache", + ".ua", + ".slidep", + ".trae", + ".idea", + ".vscode", + "node_modules", + ".next", + "dist", + "build", + "coverage", + ".venv", + "venv", + "__pycache__", + ".pytest_cache", + ".ruff_cache", + "assets", +} + +# Generated files exempt from the header requirement (relative path or bare name). +EXCLUDED_FILES = { + "next-env.d.ts", +} + + +def copyright_text() -> str: + """Standard header text; the year range ends at the current year.""" + current_year = datetime.now().year + if current_year > COPYRIGHT_START_YEAR: + years = f"{COPYRIGHT_START_YEAR}-{current_year}" + else: + years = str(COPYRIGHT_START_YEAR) + return f"Copyright (c) {years} Huawei Technologies Co., Ltd. All rights reserved." + + +def rel(path: Path) -> str: + """Return a repo-relative display path.""" + try: + return path.resolve().relative_to(REPO_ROOT).as_posix() + except ValueError: + return str(path) + + +def is_target_file(path: Path) -> bool: + """True when the file is in scope of the copyright check.""" + if path.name in EXCLUDED_FILES: + return False + if path.suffix.lower() not in COMMENT_STYLES: + return False + try: + parts = path.resolve().relative_to(REPO_ROOT).parts + except (OSError, ValueError): + return False + return not any(part in EXCLUDED_DIRS or part.startswith(".") for part in parts[:-1]) + + +def has_header(path: Path) -> bool: + """True when the copyright line appears in the file header zone.""" + try: + lines = path.read_text(encoding="utf-8-sig", errors="replace").splitlines() + except OSError: + print(f"warning: cannot read {rel(path)}", file=sys.stderr) + return True + + content_seen = 0 + for line in lines[:HEADER_SEARCH_LINES]: + stripped = line.strip() + if not stripped: + continue + if stripped.startswith("#!"): + continue + if CODING_PATTERN.match(stripped): + continue + if COPYRIGHT_PATTERN.search(stripped): + return True + content_seen += 1 + if content_seen >= CONTENT_LINE_LIMIT: + return False + return False + + +def fix_file(path: Path) -> bool: + """Insert the standard header after shebang/coding lines. True on success.""" + try: + raw = path.read_bytes() + except OSError: + print(f"warning: cannot read {rel(path)}", file=sys.stderr) + return False + + has_bom = raw.startswith(b"\xef\xbb\xbf") + try: + text = raw.decode("utf-8-sig") + except UnicodeDecodeError: + print(f"warning: skip non-utf8 file {rel(path)}", file=sys.stderr) + return False + + lines = text.splitlines(keepends=True) + newline = "\r\n" if "\r\n" in text else "\n" + + insert_at = 0 + for index, line in enumerate(lines[:3]): + stripped = line.strip() + if stripped.startswith("#!") or CODING_PATTERN.match(stripped): + insert_at = index + 1 + else: + break + + style = COMMENT_STYLES[path.suffix.lower()] + lines.insert(insert_at, f"{style} {copyright_text()}{newline}") + # Keep a blank separator line between the header and the body when needed. + if insert_at + 1 < len(lines) and lines[insert_at + 1].strip(): + lines.insert(insert_at + 1, newline) + + payload = "".join(lines).encode("utf-8") + if has_bom: + payload = b"\xef\xbb\xbf" + payload + path.write_bytes(payload) + print(f" [fixed] {rel(path)}") + return True + + +def iter_repo_files() -> list[Path]: + """Collect in-scope files across the repository (full-scan mode).""" + collected = [] + for dirpath, dirnames, filenames in os.walk(REPO_ROOT): + dirnames[:] = [ + d for d in dirnames if d not in EXCLUDED_DIRS and not d.startswith(".") + ] + for name in filenames: + candidate = Path(dirpath) / name + if name in EXCLUDED_FILES: + continue + if candidate.suffix.lower() in COMMENT_STYLES: + collected.append(candidate) + return collected + + +def read_file_list(list_path: Path) -> list[Path]: + """Read one path per line; missing files are skipped silently (deleted in PR).""" + if not list_path.exists(): + return [] + files = [] + for line in list_path.read_text(encoding="utf-8", errors="replace").splitlines(): + line = line.strip() + if not line: + continue + candidate = Path(line) if Path(line).is_absolute() else REPO_ROOT / line + if candidate.exists(): + files.append(candidate) + return files + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "paths", + nargs="*", + help="specific files to check (default: full repository scan)", + ) + parser.add_argument( + "--files", + nargs="+", + metavar="FILE", + help="specific files to check", + ) + parser.add_argument( + "--files-from", + metavar="FILE", + help="read the file list line-by-line from a text file (CI mode)", + ) + parser.add_argument( + "--fix", + action="store_true", + help="insert the standard header into files that lack it", + ) + args = parser.parse_args() + + explicit = [Path(p) for p in (args.paths + (args.files or []))] + if args.files_from: + explicit.extend(read_file_list(Path(args.files_from))) + + if explicit: + targets = [p for p in explicit if p.is_file() and is_target_file(p)] + scope = "provided file list" + else: + targets = iter_repo_files() + scope = "full repository scan" + + print(f"Copyright header check ({scope}): {len(targets)} target file(s)") + + missing = [path for path in targets if not has_header(path)] + if not missing: + print("OK: all target files carry the required copyright header.") + return 0 + + for path in missing: + print(f" [missing] {rel(path)}") + + print( + f"\n{len(missing)} file(s) missing the required header. " + "Expected comment line within the file header:\n" + f" {copyright_text()}\n" + "Comment style: '#' for .py/.sh, '--' for .sql, '//' for .ts/.tsx/.js/.jsx/.mjs.\n" + "Fix automatically with:\n" + " python scripts/check_copyright.py --fix " + ) + + if args.fix: + for path in missing: + fix_file(path) + print(f"Inserted headers into {len(missing)} file(s). Re-run to verify.") + return 0 + + return 1 + + +if __name__ == "__main__": + sys.exit(main())