From 32c9a5d66aefb5ca88dbd0d99a33735f89d849ea Mon Sep 17 00:00:00 2001 From: zz287613978 Date: Tue, 13 Oct 2026 19:32:45 +0800 Subject: [PATCH 1/4] ci: add copyright header check for changed source files - Add scripts/check_copyright.py to check and auto-fix missing copyright headers on .py/.sh/.sql/.ts/.tsx/.js/.jsx/.mjs files, with a --files-from mode for incremental checks - Add .github/workflows/copyright-check.yml to incrementally check PRs targeting develop/release/hotfix branches, with an optional full scan via workflow_dispatch - Require the header in AGENTS.md shared constraints and add a .cursor/rules thin entry for compatibility - Document the requirement and local verify/fix commands in CONTRIBUTING.md --- .cursor/rules/copyright_header.mdc | 10 + .github/workflows/copyright-check.yml | 63 ++++++ AGENTS.md | 1 + CONTRIBUTING.md | 22 +++ scripts/check_copyright.py | 266 ++++++++++++++++++++++++++ 5 files changed, 362 insertions(+) create mode 100644 .cursor/rules/copyright_header.mdc create mode 100644 .github/workflows/copyright-check.yml create mode 100644 scripts/check_copyright.py 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..bbb1db139f --- /dev/null +++ b/.github/workflows/copyright-check.yml @@ -0,0 +1,63 @@ +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/**'] + push: + branches: [develop, 'release/**', 'hotfix/**'] + +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 + BEFORE_SHA="${{ github.event.before }}" + if [ -n "${BEFORE_SHA}" ] \ + && [ "${BEFORE_SHA}" != "0000000000000000000000000000000000000000" ] \ + && git cat-file -e "${BEFORE_SHA}^{commit}"; then + git diff --name-only --diff-filter=ACMR "${BEFORE_SHA}..HEAD" > changed_files.txt + else + git diff --name-only --diff-filter=ACMR HEAD~1..HEAD > changed_files.txt + fi + 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..5df0379a6b 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.` 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..44cd7dae4e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,6 +85,28 @@ 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. +``` + +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..b1376672c7 --- /dev/null +++ b/scripts/check_copyright.py @@ -0,0 +1,266 @@ +#!/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 pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent + +# Standard header text inserted by --fix (aligned with NOTICE). +COPYRIGHT_TEXT = "Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved." + +# 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 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()) From 4132eb1771b9e4e4a04699e6e1afd3bddd6582b7 Mon Sep 17 00:00:00 2001 From: zz287613978 Date: Tue, 13 Oct 2026 20:13:43 +0800 Subject: [PATCH 2/4] fix(ci): generate copyright year range dynamically Derive the end year of the copyright header from the current year instead of hardcoding 2025-2026, so headers inserted by --fix and the suggestion in error output stay correct in future years. The check pattern already accepts any year variant. Also document the dynamic end year in AGENTS.md and CONTRIBUTING.md. --- AGENTS.md | 2 +- CONTRIBUTING.md | 2 ++ scripts/check_copyright.py | 20 ++++++++++++++++---- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5df0379a6b..ec0a379d4e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +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.` 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] `. +- 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 44cd7dae4e..70036c6dcc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,6 +95,8 @@ The header uses the comment style of the file type (`#` for Python/Shell, `--` f # 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 diff --git a/scripts/check_copyright.py b/scripts/check_copyright.py index b1376672c7..c8a738302f 100644 --- a/scripts/check_copyright.py +++ b/scripts/check_copyright.py @@ -17,12 +17,14 @@ import os import re import sys +from datetime import datetime from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent -# Standard header text inserted by --fix (aligned with NOTICE). -COPYRIGHT_TEXT = "Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved." +# 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. @@ -81,6 +83,16 @@ } +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: @@ -154,7 +166,7 @@ def fix_file(path: Path) -> bool: break style = COMMENT_STYLES[path.suffix.lower()] - lines.insert(insert_at, f"{style} {COPYRIGHT_TEXT}{newline}") + 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) @@ -247,7 +259,7 @@ def main() -> int: print( f"\n{len(missing)} file(s) missing the required header. " "Expected comment line within the file header:\n" - f" {COPYRIGHT_TEXT}\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 " From 7db659a3c054273e47233ae7e8f1c6ff5b6b5f17 Mon Sep 17 00:00:00 2001 From: zz287613978 Date: Tue, 13 Oct 2026 20:15:53 +0800 Subject: [PATCH 3/4] fix(ci): limit copyright check workflow token permissions Add an explicit top-level permissions block restricting GITHUB_TOKEN to contents: read, matching the convention of other workflows and resolving the CodeQL workflow permissions alert. --- .github/workflows/copyright-check.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/copyright-check.yml b/.github/workflows/copyright-check.yml index bbb1db139f..8cb657e1b6 100644 --- a/.github/workflows/copyright-check.yml +++ b/.github/workflows/copyright-check.yml @@ -20,6 +20,9 @@ on: push: branches: [develop, 'release/**', 'hotfix/**'] +permissions: + contents: read + jobs: copyright-check: runs-on: ubuntu-latest From 90b7d7718b6aaf75da5d29d52efce36eb77d1233 Mon Sep 17 00:00:00 2001 From: zz287613978 Date: Wed, 14 Oct 2026 12:13:12 +0800 Subject: [PATCH 4/4] fix(ci): drop push trigger and add paths filter to copyright check Remove the push trigger: on merge commits to protected branches the two-dot diff (event.before..HEAD) includes every file from the merged feature branch, which could turn previously green PRs into unexpected red builds on develop. Incremental checking is already closed-loop on pull_request events via the three-dot diff against the base branch. Simplify the fallback diff to HEAD~1..HEAD: github.event.before is only populated for push events, so the BEFORE_SHA branch is dead code now. workflow_dispatch changed mode keeps its documented latest-commit semantics. Add a pull_request paths filter matching the checker's supported source extensions so docs-only PRs no longer trigger a fetch-depth: 0 checkout. Keep the workflow file itself in the trigger set so changes to the check are still validated end-to-end. --- .github/workflows/copyright-check.yml | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/copyright-check.yml b/.github/workflows/copyright-check.yml index 8cb657e1b6..89bc883fa7 100644 --- a/.github/workflows/copyright-check.yml +++ b/.github/workflows/copyright-check.yml @@ -17,8 +17,16 @@ on: - changed pull_request: branches: [develop, 'release/**', 'hotfix/**'] - push: - branches: [develop, 'release/**', 'hotfix/**'] + paths: + - '**.py' + - '**.sh' + - '**.sql' + - '**.ts' + - '**.tsx' + - '**.js' + - '**.jsx' + - '**.mjs' + - '.github/workflows/copyright-check.yml' permissions: contents: read @@ -45,14 +53,7 @@ jobs: if [ "${{ github.event_name }}" = "pull_request" ]; then git diff --name-only --diff-filter=ACMR "origin/${{ github.base_ref }}...HEAD" > changed_files.txt else - BEFORE_SHA="${{ github.event.before }}" - if [ -n "${BEFORE_SHA}" ] \ - && [ "${BEFORE_SHA}" != "0000000000000000000000000000000000000000" ] \ - && git cat-file -e "${BEFORE_SHA}^{commit}"; then - git diff --name-only --diff-filter=ACMR "${BEFORE_SHA}..HEAD" > changed_files.txt - else - git diff --name-only --diff-filter=ACMR HEAD~1..HEAD > changed_files.txt - fi + git diff --name-only --diff-filter=ACMR HEAD~1..HEAD > changed_files.txt fi echo "---- changed files ----" cat changed_files.txt